diff --git a/Makefile b/Makefile
index c88ac79..757bcfd 100644
--- a/Makefile
+++ b/Makefile
@@ -1,147 +1,149 @@
GITREV=`git describe | cut -c 2-`
LDFLAGS=-ldflags="-X 'github.com/writeas/writefreely.softwareVer=$(GITREV)'"
GOCMD=go
GOINSTALL=$(GOCMD) install $(LDFLAGS)
GOBUILD=$(GOCMD) build $(LDFLAGS)
GOTEST=$(GOCMD) test $(LDFLAGS)
GOGET=$(GOCMD) get
BINARY_NAME=writefreely
+BUILDPATH=build/$(BINARY_NAME)
DOCKERCMD=docker
IMAGE_NAME=writeas/writefreely
TMPBIN=./tmp
all : build
ci: ci-assets deps
cd cmd/writefreely; $(GOBUILD) -v
build: assets deps
cd cmd/writefreely; $(GOBUILD) -v -tags='sqlite'
build-no-sqlite: assets-no-sqlite deps-no-sqlite
cd cmd/writefreely; $(GOBUILD) -v -o $(BINARY_NAME)
build-linux: deps
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
$(GOGET) -u github.com/karalabe/xgo; \
fi
xgo --targets=linux/amd64, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely
build-windows: deps
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
$(GOGET) -u github.com/karalabe/xgo; \
fi
xgo --targets=windows/amd64, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely
build-darwin: deps
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
$(GOGET) -u github.com/karalabe/xgo; \
fi
xgo --targets=darwin/amd64, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely
build-arm7: deps
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
$(GOGET) -u github.com/karalabe/xgo; \
fi
xgo --targets=linux/arm-7, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely
build-docker :
$(DOCKERCMD) build -t $(IMAGE_NAME):latest -t $(IMAGE_NAME):$(GITREV) .
test:
$(GOTEST) -v ./...
run: dev-assets
$(GOINSTALL) -tags='sqlite' ./...
$(BINARY_NAME) --debug
deps :
$(GOGET) -tags='sqlite' -d -v ./...
deps-no-sqlite:
$(GOGET) -d -v ./...
install : build
cmd/writefreely/$(BINARY_NAME) --config
cmd/writefreely/$(BINARY_NAME) --gen-keys
cmd/writefreely/$(BINARY_NAME) --init-db
cd less/; $(MAKE) install $(MFLAGS)
release : clean ui assets
- mkdir build
- cp -r templates build
- cp -r pages build
- cp -r static build
- mkdir build/keys
+ mkdir -p $(BUILDPATH)
+ cp -r templates $(BUILDPATH)
+ cp -r pages $(BUILDPATH)
+ cp -r static $(BUILDPATH)
+ mkdir $(BUILDPATH)/keys
$(MAKE) build-linux
- mv build/$(BINARY_NAME)-linux-amd64 build/$(BINARY_NAME)
- cd build; tar -cvzf ../$(BINARY_NAME)_$(GITREV)_linux_amd64.tar.gz *
- rm build/$(BINARY_NAME)
+ mv build/$(BINARY_NAME)-linux-amd64 $(BUILDPATH)/$(BINARY_NAME)
+ tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_amd64.tar.gz -C build $(BINARY_NAME)
+ rm $(BUILDPATH)/$(BINARY_NAME)
$(MAKE) build-arm7
- mv build/$(BINARY_NAME)-linux-arm-7 build/$(BINARY_NAME)
- cd build; tar -cvzf ../$(BINARY_NAME)_$(GITREV)_linux_arm7.tar.gz *
- rm build/$(BINARY_NAME)
+ mv build/$(BINARY_NAME)-linux-arm-7 $(BUILDPATH)/$(BINARY_NAME)
+ tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_arm7.tar.gz -C build $(BINARY_NAME)
+ rm $(BUILDPATH)/$(BINARY_NAME)
$(MAKE) build-darwin
- mv build/$(BINARY_NAME)-darwin-10.6-amd64 build/$(BINARY_NAME)
- cd build; tar -cvzf ../$(BINARY_NAME)_$(GITREV)_macos_amd64.tar.gz *
- rm build/$(BINARY_NAME)
+ mv build/$(BINARY_NAME)-darwin-10.6-amd64 $(BUILDPATH)/$(BINARY_NAME)
+ tar -cvzf $(BINARY_NAME)_$(GITREV)_macos_amd64.tar.gz -C build $(BINARY_NAME)
+ rm $(BUILDPATH)/$(BINARY_NAME)
$(MAKE) build-windows
- mv build/$(BINARY_NAME)-windows-4.0-amd64.exe build/$(BINARY_NAME).exe
- cd build; zip -r ../$(BINARY_NAME)_$(GITREV)_windows_amd64.zip ./*
+ mv build/$(BINARY_NAME)-windows-4.0-amd64.exe $(BUILDPATH)/$(BINARY_NAME).exe
+ cd build; zip -r ../$(BINARY_NAME)_$(GITREV)_windows_amd64.zip ./$(BINARY_NAME)
+ rm $(BUILDPATH)/$(BINARY_NAME)
$(MAKE) build-docker
$(MAKE) release-docker
# This assumes you're on linux/amd64
release-linux : clean ui
- mkdir build
- cp -r templates build
- cp -r pages build
- cp -r static build
- mkdir build/keys
+ mkdir -p $(BUILDPATH)
+ cp -r templates $(BUILDPATH)
+ cp -r pages $(BUILDPATH)
+ cp -r static $(BUILDPATH)
+ mkdir $(BUILDPATH)/keys
$(MAKE) build-no-sqlite
- mv cmd/writefreely/$(BINARY_NAME) build/$(BINARY_NAME)
- cd build; tar -cvzf ../$(BINARY_NAME)_$(GITREV)_linux_amd64.tar.gz *
+ mv cmd/writefreely/$(BINARY_NAME) $(BUILDPATH)/$(BINARY_NAME)
+ tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_amd64.tar.gz -C build $(BINARY_NAME)
release-docker :
$(DOCKERCMD) push $(IMAGE_NAME)
ui : force_look
cd less/; $(MAKE) $(MFLAGS)
assets : generate
go-bindata -pkg writefreely -ignore=\\.gitignore -tags="!wflib" schema.sql sqlite.sql
assets-no-sqlite: generate
go-bindata -pkg writefreely -ignore=\\.gitignore -tags="!wflib" schema.sql
dev-assets : generate
go-bindata -pkg writefreely -ignore=\\.gitignore -debug -tags="!wflib" schema.sql sqlite.sql
lib-assets : generate
go-bindata -pkg writefreely -ignore=\\.gitignore -o bindata-lib.go -tags="wflib" schema.sql
generate :
@hash go-bindata > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
$(GOGET) -u github.com/jteeuwen/go-bindata/go-bindata; \
fi
$(TMPBIN):
mkdir -p $(TMPBIN)
$(TMPBIN)/go-bindata: deps $(TMPBIN)
$(GOBUILD) -o $(TMPBIN)/go-bindata github.com/jteeuwen/go-bindata/go-bindata
$(TMPBIN)/xgo: deps $(TMPBIN)
$(GOBUILD) -o $(TMPBIN)/xgo github.com/karalabe/xgo
ci-assets : $(TMPBIN)/go-bindata
$(TMPBIN)/go-bindata -pkg writefreely -ignore=\\.gitignore -tags="!wflib" schema.sql sqlite.sql
clean :
-rm -rf build
-rm -rf tmp
cd less/; $(MAKE) clean $(MFLAGS)
force_look :
true
diff --git a/README.md b/README.md
index 4f0b6bb..68da89b 100644
--- a/README.md
+++ b/README.md
@@ -1,94 +1,94 @@
Not found.
{{end}}")),
Gone: template.Must(template.New("").Parse("{{define \"base\"}}Gone.
{{end}}")),
InternalServerError: template.Must(template.New("").Parse("{{define \"base\"}}Internal server error.
{{end}}")),
Blank: template.Must(template.New("").Parse("{{define \"base\"}}
Log in to {{.SiteName}}
{{if .Flashes}}
{{range .Flashes}}{{.}} {{end}}
{{end}}
{{if and (not .SingleUser) .OpenRegistration}}
{{if .Message}}{{.Message}}{{else}}No account yet? Sign up to start a blog.{{end}}
{{end}}
{{end}}
diff --git a/postrender.go b/postrender.go
index af715be..83fb5ad 100644
--- a/postrender.go
+++ b/postrender.go
@@ -1,228 +1,236 @@
/*
* Copyright © 2018 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*
* WriteFreely is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, included
* in the LICENSE file in this source code package.
*/
package writefreely
import (
"fmt"
- "github.com/microcosm-cc/bluemonday"
- stripmd "github.com/writeas/go-strip-markdown"
- "github.com/writeas/saturday"
- "github.com/writeas/web-core/stringmanip"
- "github.com/writeas/writefreely/parse"
"html"
"html/template"
"regexp"
"strings"
"unicode"
"unicode/utf8"
+
+ "github.com/microcosm-cc/bluemonday"
+ stripmd "github.com/writeas/go-strip-markdown"
+ blackfriday "github.com/writeas/saturday"
+ "github.com/writeas/web-core/stringmanip"
+ "github.com/writeas/writefreely/config"
+ "github.com/writeas/writefreely/parse"
)
var (
blockReg = regexp.MustCompile("<(ul|ol|blockquote)>\n")
endBlockReg = regexp.MustCompile("([a-z]+)>\n(ul|ol|blockquote)>")
youtubeReg = regexp.MustCompile("(https?://www.youtube.com/embed/[a-zA-Z0-9\\-_]+)(\\?[^\t\n\f\r \"']+)?")
titleElementReg = regexp.MustCompile("?h[1-6]>")
hashtagReg = regexp.MustCompile(`{{\[\[\|\|([^|]+)\|\|\]\]}}`)
markeddownReg = regexp.MustCompile("
(.+)
")
)
-func (p *Post) formatContent(c *Collection, isOwner bool) {
+func (p *Post) formatContent(cfg *config.Config, c *Collection, isOwner bool) {
baseURL := c.CanonicalURL()
+ // TODO: redundant
if !isSingleUser {
baseURL = "/" + c.Alias + "/"
}
p.HTMLTitle = template.HTML(applyBasicMarkdown([]byte(p.Title.String)))
- p.HTMLContent = template.HTML(applyMarkdown([]byte(p.Content), baseURL))
+ p.HTMLContent = template.HTML(applyMarkdown([]byte(p.Content), baseURL, cfg))
if exc := strings.Index(string(p.Content), ""); exc > -1 {
- p.HTMLExcerpt = template.HTML(applyMarkdown([]byte(p.Content[:exc]), baseURL))
+ p.HTMLExcerpt = template.HTML(applyMarkdown([]byte(p.Content[:exc]), baseURL, cfg))
}
}
-func (p *PublicPost) formatContent(isOwner bool) {
- p.Post.formatContent(&p.Collection.Collection, isOwner)
+func (p *PublicPost) formatContent(cfg *config.Config, isOwner bool) {
+ p.Post.formatContent(cfg, &p.Collection.Collection, isOwner)
}
-func applyMarkdown(data []byte, baseURL string) string {
- return applyMarkdownSpecial(data, false, baseURL)
+func applyMarkdown(data []byte, baseURL string, cfg *config.Config) string {
+ return applyMarkdownSpecial(data, false, baseURL, cfg)
}
-func applyMarkdownSpecial(data []byte, skipNoFollow bool, baseURL string) string {
+func applyMarkdownSpecial(data []byte, skipNoFollow bool, baseURL string, cfg *config.Config) string {
mdExtensions := 0 |
blackfriday.EXTENSION_TABLES |
blackfriday.EXTENSION_FENCED_CODE |
blackfriday.EXTENSION_AUTOLINK |
blackfriday.EXTENSION_STRIKETHROUGH |
blackfriday.EXTENSION_SPACE_HEADERS |
blackfriday.EXTENSION_AUTO_HEADER_IDS
htmlFlags := 0 |
blackfriday.HTML_USE_SMARTYPANTS |
blackfriday.HTML_SMARTYPANTS_DASHES
if baseURL != "" {
htmlFlags |= blackfriday.HTML_HASHTAGS
}
// Generate Markdown
md := blackfriday.Markdown([]byte(data), blackfriday.HtmlRenderer(htmlFlags, "", ""), mdExtensions)
if baseURL != "" {
// Replace special text generated by Markdown parser
- md = []byte(hashtagReg.ReplaceAll(md, []byte("
# $1 ")))
+ tagPrefix := baseURL + "tag:"
+ if cfg.App.Chorus {
+ tagPrefix = "/read/t/"
+ }
+ md = []byte(hashtagReg.ReplaceAll(md, []byte("
# $1 ")))
}
// Strip out bad HTML
policy := getSanitizationPolicy()
policy.RequireNoFollowOnLinks(!skipNoFollow)
outHTML := string(policy.SanitizeBytes(md))
// Strip newlines on certain block elements that render with them
outHTML = blockReg.ReplaceAllString(outHTML, "<$1>")
outHTML = endBlockReg.ReplaceAllString(outHTML, "$1>$2>")
// Remove all query parameters on YouTube embed links
// TODO: make this more specific. Taking the nuclear approach here to strip ?autoplay=1
outHTML = youtubeReg.ReplaceAllString(outHTML, "$1")
return outHTML
}
func applyBasicMarkdown(data []byte) string {
mdExtensions := 0 |
blackfriday.EXTENSION_STRIKETHROUGH |
blackfriday.EXTENSION_SPACE_HEADERS |
blackfriday.EXTENSION_HEADER_IDS
htmlFlags := 0 |
blackfriday.HTML_SKIP_HTML |
blackfriday.HTML_USE_SMARTYPANTS |
blackfriday.HTML_SMARTYPANTS_DASHES
// Generate Markdown
md := blackfriday.Markdown([]byte(data), blackfriday.HtmlRenderer(htmlFlags, "", ""), mdExtensions)
// Strip out bad HTML
policy := bluemonday.UGCPolicy()
policy.AllowAttrs("class", "id").Globally()
outHTML := string(policy.SanitizeBytes(md))
outHTML = markeddownReg.ReplaceAllString(outHTML, "$1")
outHTML = strings.TrimRightFunc(outHTML, unicode.IsSpace)
return outHTML
}
func postTitle(content, friendlyId string) string {
const maxTitleLen = 80
// Strip HTML tags with bluemonday's StrictPolicy, then unescape the HTML
// entities added in by sanitizing the content.
content = html.UnescapeString(bluemonday.StrictPolicy().Sanitize(content))
content = strings.TrimLeftFunc(stripmd.Strip(content), unicode.IsSpace)
eol := strings.IndexRune(content, '\n')
blankLine := strings.Index(content, "\n\n")
if blankLine != -1 && blankLine <= eol && blankLine <= assumedTitleLen {
return strings.TrimSpace(content[:blankLine])
} else if utf8.RuneCountInString(content) <= maxTitleLen {
return content
}
return friendlyId
}
// TODO: fix duplicated code from postTitle. postTitle is a widely used func we
// don't have time to investigate right now.
func friendlyPostTitle(content, friendlyId string) string {
const maxTitleLen = 80
// Strip HTML tags with bluemonday's StrictPolicy, then unescape the HTML
// entities added in by sanitizing the content.
content = html.UnescapeString(bluemonday.StrictPolicy().Sanitize(content))
content = strings.TrimLeftFunc(stripmd.Strip(content), unicode.IsSpace)
eol := strings.IndexRune(content, '\n')
blankLine := strings.Index(content, "\n\n")
if blankLine != -1 && blankLine <= eol && blankLine <= assumedTitleLen {
return strings.TrimSpace(content[:blankLine])
} else if eol == -1 && utf8.RuneCountInString(content) <= maxTitleLen {
return content
}
title, truncd := parse.TruncToWord(parse.PostLede(content, true), maxTitleLen)
if truncd {
title += "..."
}
return title
}
func getSanitizationPolicy() *bluemonday.Policy {
policy := bluemonday.UGCPolicy()
policy.AllowAttrs("src", "style").OnElements("iframe", "video", "audio")
policy.AllowAttrs("src", "type").OnElements("source")
policy.AllowAttrs("frameborder", "width", "height").Matching(bluemonday.Integer).OnElements("iframe")
policy.AllowAttrs("allowfullscreen").OnElements("iframe")
policy.AllowAttrs("controls", "loop", "muted", "autoplay").OnElements("video")
policy.AllowAttrs("controls", "loop", "muted", "autoplay", "preload").OnElements("audio")
policy.AllowAttrs("target").OnElements("a")
+ policy.AllowAttrs("title").OnElements("abbr")
policy.AllowAttrs("style", "class", "id").Globally()
policy.AllowURLSchemes("http", "https", "mailto", "xmpp")
return policy
}
func sanitizePost(content string) string {
return strings.Replace(content, "<", "<", -1)
}
// postDescription generates a description based on the given post content,
// title, and post ID. This doesn't consider a V2 post field, `title` when
// choosing what to generate. In case a post has a title, this function will
// fail, and logic should instead be implemented to skip this when there's no
// title, like so:
// var desc string
// if title == "" {
// desc = postDescription(content, title, friendlyId)
// } else {
// desc = shortPostDescription(content)
// }
func postDescription(content, title, friendlyId string) string {
maxLen := 140
if content == "" {
content = "WriteFreely is a painless, simple, federated blogging platform."
} else {
fmtStr := "%s"
truncation := 0
if utf8.RuneCountInString(content) > maxLen {
// Post is longer than the max description, so let's show a better description
fmtStr = "%s..."
truncation = 3
}
if title == friendlyId {
// No specific title was found; simply truncate the post, starting at the beginning
content = fmt.Sprintf(fmtStr, strings.Replace(stringmanip.Substring(content, 0, maxLen-truncation), "\n", " ", -1))
} else {
// There was a title, so return a real description
blankLine := strings.Index(content, "\n\n")
if blankLine < 0 {
blankLine = 0
}
truncd := stringmanip.Substring(content, blankLine, blankLine+maxLen-truncation)
contentNoNL := strings.Replace(truncd, "\n", " ", -1)
content = strings.TrimSpace(fmt.Sprintf(fmtStr, contentNoNL))
}
}
return content
}
func shortPostDescription(content string) string {
maxLen := 140
fmtStr := "%s"
truncation := 0
if utf8.RuneCountInString(content) > maxLen {
// Post is longer than the max description, so let's show a better description
fmtStr = "%s..."
truncation = 3
}
return strings.TrimSpace(fmt.Sprintf(fmtStr, strings.Replace(stringmanip.Substring(content, 0, maxLen-truncation), "\n", " ", -1)))
}
diff --git a/posts.go b/posts.go
index 2f3606f..6410735 100644
--- a/posts.go
+++ b/posts.go
@@ -1,1454 +1,1535 @@
/*
* Copyright © 2018-2019 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*
* WriteFreely is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, included
* in the LICENSE file in this source code package.
*/
package writefreely
import (
"database/sql"
"encoding/json"
"fmt"
"html/template"
"net/http"
"regexp"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/guregu/null"
"github.com/guregu/null/zero"
"github.com/kylemcc/twitter-text-go/extract"
"github.com/microcosm-cc/bluemonday"
stripmd "github.com/writeas/go-strip-markdown"
"github.com/writeas/impart"
"github.com/writeas/monday"
"github.com/writeas/slug"
"github.com/writeas/web-core/activitystreams"
"github.com/writeas/web-core/bots"
"github.com/writeas/web-core/converter"
"github.com/writeas/web-core/i18n"
"github.com/writeas/web-core/log"
"github.com/writeas/web-core/tags"
+ "github.com/writeas/writefreely/config"
"github.com/writeas/writefreely/page"
"github.com/writeas/writefreely/parse"
)
const (
// Post ID length bounds
minIDLen = 10
maxIDLen = 10
userPostIDLen = 10
postIDLen = 10
postMetaDateFormat = "2006-01-02 15:04:05"
)
type (
AnonymousPost struct {
ID string
Content string
HTMLContent template.HTML
Font string
Language string
Direction string
Title string
GenTitle string
Description string
Author string
Views int64
IsPlainText bool
IsCode bool
IsLinkable bool
}
AuthenticatedPost struct {
ID string `json:"id" schema:"id"`
Web bool `json:"web" schema:"web"`
*SubmittedPost
}
// SubmittedPost represents a post supplied by a client for publishing or
// updating. Since Title and Content can be updated to "", they are
// pointers that can be easily tested to detect changes.
SubmittedPost struct {
Slug *string `json:"slug" schema:"slug"`
Title *string `json:"title" schema:"title"`
Content *string `json:"body" schema:"body"`
Font string `json:"font" schema:"font"`
IsRTL converter.NullJSONBool `json:"rtl" schema:"rtl"`
Language converter.NullJSONString `json:"lang" schema:"lang"`
Created *string `json:"created" schema:"created"`
}
// Post represents a post as found in the database.
Post struct {
ID string `db:"id" json:"id"`
Slug null.String `db:"slug" json:"slug,omitempty"`
Font string `db:"text_appearance" json:"appearance"`
Language zero.String `db:"language" json:"language"`
RTL zero.Bool `db:"rtl" json:"rtl"`
Privacy int64 `db:"privacy" json:"-"`
OwnerID null.Int `db:"owner_id" json:"-"`
CollectionID null.Int `db:"collection_id" json:"-"`
PinnedPosition null.Int `db:"pinned_position" json:"-"`
Created time.Time `db:"created" json:"created"`
Updated time.Time `db:"updated" json:"updated"`
ViewCount int64 `db:"view_count" json:"-"`
Title zero.String `db:"title" json:"title"`
HTMLTitle template.HTML `db:"title" json:"-"`
Content string `db:"content" json:"body"`
HTMLContent template.HTML `db:"content" json:"-"`
HTMLExcerpt template.HTML `db:"content" json:"-"`
Tags []string `json:"tags"`
Images []string `json:"images,omitempty"`
OwnerName string `json:"owner,omitempty"`
}
// PublicPost holds properties for a publicly returned post, i.e. a post in
// a context where the viewer may not be the owner. As such, sensitive
// metadata for the post is hidden and properties supporting the display of
// the post are added.
PublicPost struct {
*Post
IsSubdomain bool `json:"-"`
IsTopLevel bool `json:"-"`
DisplayDate string `json:"-"`
Views int64 `json:"views"`
Owner *PublicUser `json:"-"`
IsOwner bool `json:"-"`
Collection *CollectionObj `json:"collection,omitempty"`
}
RawPost struct {
Id, Slug string
Title string
Content string
Views int64
Font string
Created time.Time
IsRTL sql.NullBool
Language sql.NullString
OwnerID int64
CollectionID sql.NullInt64
Found bool
Gone bool
}
AnonymousAuthPost struct {
ID string `json:"id"`
Token string `json:"token"`
}
ClaimPostRequest struct {
*AnonymousAuthPost
CollectionAlias string `json:"collection"`
CreateCollection bool `json:"create_collection"`
// Generated properties
Slug string `json:"-"`
}
ClaimPostResult struct {
ID string `json:"id,omitempty"`
Code int `json:"code,omitempty"`
ErrorMessage string `json:"error_msg,omitempty"`
Post *PublicPost `json:"post,omitempty"`
}
)
func (p *Post) Direction() string {
if p.RTL.Valid {
if p.RTL.Bool {
return "rtl"
}
return "ltr"
}
return "auto"
}
// DisplayTitle dynamically generates a title from the Post's contents if it
// doesn't already have an explicit title.
func (p *Post) DisplayTitle() string {
if p.Title.String != "" {
return p.Title.String
}
t := friendlyPostTitle(p.Content, p.ID)
return t
}
// PlainDisplayTitle dynamically generates a title from the Post's contents if it
// doesn't already have an explicit title.
func (p *Post) PlainDisplayTitle() string {
if t := stripmd.Strip(p.DisplayTitle()); t != "" {
return t
}
return p.ID
}
// FormattedDisplayTitle dynamically generates a title from the Post's contents if it
// doesn't already have an explicit title.
func (p *Post) FormattedDisplayTitle() template.HTML {
if p.HTMLTitle != "" {
return p.HTMLTitle
}
return template.HTML(p.DisplayTitle())
}
// Summary gives a shortened summary of the post based on the post's title,
// especially for display in a longer list of posts. It extracts a summary for
// posts in the Title\n\nBody format, returning nothing if the entire was short
// enough that the extracted title == extracted summary.
func (p Post) Summary() string {
if p.Content == "" {
return ""
}
// Strip out HTML
p.Content = bluemonday.StrictPolicy().Sanitize(p.Content)
// and Markdown
p.Content = stripmd.Strip(p.Content)
title := p.Title.String
var desc string
if title == "" {
// No title, so generate one
title = friendlyPostTitle(p.Content, p.ID)
desc = postDescription(p.Content, title, p.ID)
if desc == title {
return ""
}
return desc
}
return shortPostDescription(p.Content)
}
// Excerpt shows any text that comes before a (more) tag.
// TODO: use HTMLExcerpt in templates instead of this method
func (p *Post) Excerpt() template.HTML {
return p.HTMLExcerpt
}
func (p *Post) CreatedDate() string {
return p.Created.Format("2006-01-02")
}
func (p *Post) Created8601() string {
return p.Created.Format("2006-01-02T15:04:05Z")
}
func (p *Post) IsScheduled() bool {
return p.Created.After(time.Now())
}
func (p *Post) HasTag(tag string) bool {
// Regexp looks for tag and has a non-capturing group at the end looking
// for the end of the word.
// Assisted by: https://stackoverflow.com/a/35192941/1549194
hasTag, _ := regexp.MatchString("#"+tag+`(?:[[:punct:]]|\s|\z)`, p.Content)
return hasTag
}
func (p *Post) HasTitleLink() bool {
if p.Title.String == "" {
return false
}
hasLink, _ := regexp.MatchString(`([^!]+|^)\[.+\]\(.+\)`, p.Title.String)
return hasLink
}
func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
friendlyID := vars["post"]
// NOTE: until this is done better, be sure to keep this in parity with
// isRaw() and viewCollectionPost()
isJSON := strings.HasSuffix(friendlyID, ".json")
isXML := strings.HasSuffix(friendlyID, ".xml")
isCSS := strings.HasSuffix(friendlyID, ".css")
isMarkdown := strings.HasSuffix(friendlyID, ".md")
isRaw := strings.HasSuffix(friendlyID, ".txt") || isJSON || isXML || isCSS || isMarkdown
// Display reserved page if that is requested resource
if t, ok := pages[r.URL.Path[1:]+".tmpl"]; ok {
return handleTemplatedPage(app, w, r, t)
} else if (strings.Contains(r.URL.Path, ".") && !isRaw && !isMarkdown) || r.URL.Path == "/robots.txt" || r.URL.Path == "/manifest.json" {
// Serve static file
app.shttp.ServeHTTP(w, r)
return nil
}
// Display collection if this is a collection
c, _ := app.db.GetCollection(friendlyID)
if c != nil {
return impart.HTTPError{http.StatusMovedPermanently, fmt.Sprintf("/%s/", friendlyID)}
}
// Normalize the URL, redirecting user to consistent post URL
if friendlyID != strings.ToLower(friendlyID) {
return impart.HTTPError{http.StatusMovedPermanently, fmt.Sprintf("/%s", strings.ToLower(friendlyID))}
}
ext := ""
if isRaw {
parts := strings.Split(friendlyID, ".")
friendlyID = parts[0]
if len(parts) > 1 {
ext = "." + parts[1]
}
}
var ownerID sql.NullInt64
var title string
var content string
var font string
var language []byte
var rtl []byte
var views int64
var post *AnonymousPost
var found bool
var gone bool
fixedID := slug.Make(friendlyID)
if fixedID != friendlyID {
return impart.HTTPError{http.StatusFound, fmt.Sprintf("/%s%s", fixedID, ext)}
}
err := app.db.QueryRow(fmt.Sprintf("SELECT owner_id, title, content, text_appearance, view_count, language, rtl FROM posts WHERE id = ?"), friendlyID).Scan(&ownerID, &title, &content, &font, &views, &language, &rtl)
switch {
case err == sql.ErrNoRows:
found = false
// Output the error in the correct format
if isJSON {
content = "{\"error\": \"Post not found.\"}"
} else if isRaw {
content = "Post not found."
} else {
return ErrPostNotFound
}
case err != nil:
found = false
log.Error("Post loading err: %s\n", err)
return ErrInternalGeneral
default:
found = true
var d string
if len(rtl) == 0 {
d = "auto"
} else if rtl[0] == 49 {
// TODO: find a cleaner way to get this (possibly NULL) value
d = "rtl"
} else {
d = "ltr"
}
generatedTitle := friendlyPostTitle(content, friendlyID)
sanitizedContent := content
if font != "code" {
sanitizedContent = template.HTMLEscapeString(content)
}
var desc string
if title == "" {
desc = postDescription(content, title, friendlyID)
} else {
desc = shortPostDescription(content)
}
post = &AnonymousPost{
ID: friendlyID,
Content: sanitizedContent,
Title: title,
GenTitle: generatedTitle,
Description: desc,
Author: "",
Font: font,
IsPlainText: isRaw,
IsCode: font == "code",
IsLinkable: font != "code",
Views: views,
Language: string(language),
Direction: d,
}
if !isRaw {
- post.HTMLContent = template.HTML(applyMarkdown([]byte(content), ""))
+ post.HTMLContent = template.HTML(applyMarkdown([]byte(content), "", app.cfg))
}
}
+ suspended, err := app.db.IsUserSuspended(ownerID.Int64)
+ if err != nil {
+ log.Error("view post: %v", err)
+ return ErrInternalGeneral
+ }
+
// Check if post has been unpublished
if content == "" {
gone = true
if isJSON {
content = "{\"error\": \"Post was unpublished.\"}"
} else if isCSS {
content = ""
} else if isRaw {
content = "Post was unpublished."
} else {
return ErrPostUnpublished
}
}
var u = &User{}
if isRaw {
contentType := "text/plain"
if isJSON {
contentType = "application/json"
} else if isCSS {
contentType = "text/css"
} else if isXML {
contentType = "application/xml"
} else if isMarkdown {
contentType = "text/markdown"
}
w.Header().Set("Content-Type", fmt.Sprintf("%s; charset=utf-8", contentType))
if isMarkdown && post.Title != "" {
fmt.Fprintf(w, "%s\n", post.Title)
for i := 1; i <= len(post.Title); i++ {
fmt.Fprintf(w, "=")
}
fmt.Fprintf(w, "\n\n")
}
fmt.Fprint(w, content)
if !found {
return ErrPostNotFound
} else if gone {
return ErrPostUnpublished
}
} else {
var err error
page := struct {
*AnonymousPost
page.StaticPage
- Username string
- IsOwner bool
- SiteURL string
+ Username string
+ IsOwner bool
+ SiteURL string
+ Suspended bool
}{
AnonymousPost: post,
StaticPage: pageForReq(app, r),
SiteURL: app.cfg.App.Host,
}
if u = getUserSession(app, r); u != nil {
page.Username = u.Username
page.IsOwner = ownerID.Valid && ownerID.Int64 == u.ID
}
+ if !page.IsOwner && suspended {
+ return ErrPostNotFound
+ }
+ page.Suspended = suspended
err = templates["post"].ExecuteTemplate(w, "post", page)
if err != nil {
log.Error("Post template execute error: %v", err)
}
}
go func() {
if u != nil && ownerID.Valid && ownerID.Int64 == u.ID {
// Post is owned by someone; skip view increment since that person is viewing this post.
return
}
// Update stats for non-raw post views
if !isRaw && r.Method != "HEAD" && !bots.IsBot(r.UserAgent()) {
_, err := app.db.Exec("UPDATE posts SET view_count = view_count + 1 WHERE id = ?", friendlyID)
if err != nil {
log.Error("Unable to update posts count: %v", err)
}
}
}()
return nil
}
// API v2 funcs
// newPost creates a new post with or without an owning Collection.
//
// Endpoints:
// /posts
// /posts?collection={alias}
// ? /collections/{alias}/posts
func newPost(app *App, w http.ResponseWriter, r *http.Request) error {
- reqJSON := IsJSON(r.Header.Get("Content-Type"))
+ reqJSON := IsJSON(r)
vars := mux.Vars(r)
collAlias := vars["alias"]
if collAlias == "" {
collAlias = r.FormValue("collection")
}
accessToken := r.Header.Get("Authorization")
if accessToken == "" {
// TODO: remove this
accessToken = r.FormValue("access_token")
}
// FIXME: determine web submission with Content-Type header
var u *User
var userID int64 = -1
var username string
if accessToken == "" {
u = getUserSession(app, r)
if u != nil {
userID = u.ID
username = u.Username
}
} else {
userID = app.db.GetUserID(accessToken)
}
+ suspended, err := app.db.IsUserSuspended(userID)
+ if err != nil {
+ log.Error("new post: %v", err)
+ return ErrInternalGeneral
+ }
+ if suspended {
+ return ErrUserSuspended
+ }
+
if userID == -1 {
return ErrNotLoggedIn
}
if accessToken == "" && u == nil && collAlias != "" {
return impart.HTTPError{http.StatusBadRequest, "Parameter `access_token` required."}
}
// Get post data
var p *SubmittedPost
if reqJSON {
decoder := json.NewDecoder(r.Body)
- err := decoder.Decode(&p)
+ err = decoder.Decode(&p)
if err != nil {
log.Error("Couldn't parse new post JSON request: %v\n", err)
return ErrBadJSON
}
if p.Title == nil {
t := ""
p.Title = &t
}
if strings.TrimSpace(*(p.Content)) == "" {
return ErrNoPublishableContent
}
} else {
post := r.FormValue("body")
appearance := r.FormValue("font")
title := r.FormValue("title")
rtlValue := r.FormValue("rtl")
langValue := r.FormValue("lang")
if strings.TrimSpace(post) == "" {
return ErrNoPublishableContent
}
var isRTL, rtlValid bool
if rtlValue == "auto" && langValue != "" {
isRTL = i18n.LangIsRTL(langValue)
rtlValid = true
} else {
isRTL = rtlValue == "true"
rtlValid = rtlValue != "" && langValue != ""
}
// Create a new post
p = &SubmittedPost{
Title: &title,
Content: &post,
Font: appearance,
IsRTL: converter.NullJSONBool{sql.NullBool{Bool: isRTL, Valid: rtlValid}},
Language: converter.NullJSONString{sql.NullString{String: langValue, Valid: langValue != ""}},
}
}
if !p.isFontValid() {
p.Font = "norm"
}
var newPost *PublicPost = &PublicPost{}
var coll *Collection
- var err error
if accessToken != "" {
newPost, err = app.db.CreateOwnedPost(p, accessToken, collAlias, app.cfg.App.Host)
} else {
//return ErrNotLoggedIn
// TODO: verify user is logged in
var collID int64
if collAlias != "" {
coll, err = app.db.GetCollection(collAlias)
if err != nil {
return err
}
coll.hostName = app.cfg.App.Host
if coll.OwnerID != u.ID {
return ErrForbiddenCollection
}
collID = coll.ID
}
// TODO: return PublicPost from createPost
newPost.Post, err = app.db.CreatePost(userID, collID, p)
}
if err != nil {
return err
}
if coll != nil {
coll.ForPublic()
newPost.Collection = &CollectionObj{Collection: *coll}
}
newPost.extractData()
newPost.OwnerName = username
// Write success now
response := impart.WriteSuccess(w, newPost, http.StatusCreated)
if newPost.Collection != nil && !app.cfg.App.Private && app.cfg.App.Federation && !newPost.Created.After(time.Now()) {
go federatePost(app, newPost, newPost.Collection.ID, false)
}
return response
}
func existingPost(app *App, w http.ResponseWriter, r *http.Request) error {
- reqJSON := IsJSON(r.Header.Get("Content-Type"))
+ reqJSON := IsJSON(r)
vars := mux.Vars(r)
postID := vars["post"]
p := AuthenticatedPost{ID: postID}
var err error
if reqJSON {
// Decode JSON request
decoder := json.NewDecoder(r.Body)
err = decoder.Decode(&p)
if err != nil {
log.Error("Couldn't parse post update JSON request: %v\n", err)
return ErrBadJSON
}
} else {
err = r.ParseForm()
if err != nil {
log.Error("Couldn't parse post update form request: %v\n", err)
return ErrBadFormData
}
// Can't decode to a nil SubmittedPost property, so create instance now
p.SubmittedPost = &SubmittedPost{}
err = app.formDecoder.Decode(&p, r.PostForm)
if err != nil {
log.Error("Couldn't decode post update form request: %v\n", err)
return ErrBadFormData
}
}
if p.Web {
p.IsRTL.Valid = true
}
if p.SubmittedPost == nil {
return ErrPostNoUpdatableVals
}
// Ensure an access token was given
accessToken := r.Header.Get("Authorization")
// Get user's cookie session if there's no token
var u *User
//var username string
if accessToken == "" {
u = getUserSession(app, r)
if u != nil {
//username = u.Username
}
}
if u == nil && accessToken == "" {
return ErrNoAccessToken
}
// Get user ID from current session or given access token, if one was given.
var userID int64
if u != nil {
userID = u.ID
} else if accessToken != "" {
userID, err = AuthenticateUser(app.db, accessToken)
if err != nil {
return err
}
}
+ suspended, err := app.db.IsUserSuspended(userID)
+ if err != nil {
+ log.Error("existing post: %v", err)
+ return ErrInternalGeneral
+ }
+ if suspended {
+ return ErrUserSuspended
+ }
+
// Modify post struct
p.ID = postID
err = app.db.UpdateOwnedPost(&p, userID)
if err != nil {
if reqJSON {
return err
}
if err, ok := err.(impart.HTTPError); ok {
addSessionFlash(app, w, r, err.Message, nil)
} else {
addSessionFlash(app, w, r, err.Error(), nil)
}
}
var pRes *PublicPost
pRes, err = app.db.GetPost(p.ID, 0)
if reqJSON {
if err != nil {
return err
}
pRes.extractData()
}
if pRes.CollectionID.Valid {
coll, err := app.db.GetCollectionBy("id = ?", pRes.CollectionID.Int64)
if err == nil && !app.cfg.App.Private && app.cfg.App.Federation {
coll.hostName = app.cfg.App.Host
pRes.Collection = &CollectionObj{Collection: *coll}
go federatePost(app, pRes, pRes.Collection.ID, true)
}
}
// Write success now
if reqJSON {
return impart.WriteSuccess(w, pRes, http.StatusOK)
}
addSessionFlash(app, w, r, "Changes saved.", nil)
collectionAlias := vars["alias"]
redirect := "/" + postID + "/meta"
if collectionAlias != "" {
collPre := "/" + collectionAlias
if app.cfg.App.SingleUser {
collPre = ""
}
redirect = collPre + "/" + pRes.Slug.String + "/edit/meta"
} else {
if app.cfg.App.SingleUser {
redirect = "/d" + redirect
}
}
w.Header().Set("Location", redirect)
w.WriteHeader(http.StatusFound)
return nil
}
func deletePost(app *App, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
friendlyID := vars["post"]
editToken := r.FormValue("token")
var ownerID int64
var u *User
accessToken := r.Header.Get("Authorization")
if accessToken == "" && editToken == "" {
u = getUserSession(app, r)
if u == nil {
return ErrNoAccessToken
}
}
var res sql.Result
var t *sql.Tx
var err error
var collID sql.NullInt64
var coll *Collection
var pp *PublicPost
if editToken != "" {
// TODO: SELECT owner_id, as well, and return appropriate error if NULL instead of running two queries
var dummy int64
err = app.db.QueryRow("SELECT 1 FROM posts WHERE id = ?", friendlyID).Scan(&dummy)
switch {
case err == sql.ErrNoRows:
return impart.HTTPError{http.StatusNotFound, "Post not found."}
}
err = app.db.QueryRow("SELECT 1 FROM posts WHERE id = ? AND owner_id IS NULL", friendlyID).Scan(&dummy)
switch {
case err == sql.ErrNoRows:
// Post already has an owner. This could provide a bad experience
// for the user, but it's more important to ensure data isn't lost
// unexpectedly. So prevent deletion via token.
return impart.HTTPError{http.StatusConflict, "This post belongs to some user (hopefully yours). Please log in and delete it from that user's account."}
}
res, err = app.db.Exec("DELETE FROM posts WHERE id = ? AND modify_token = ? AND owner_id IS NULL", friendlyID, editToken)
} else if accessToken != "" || u != nil {
// Caller provided some way to authenticate; assume caller expects the
// post to be deleted based on a specific post owner, thus we should
// return corresponding errors.
if accessToken != "" {
ownerID = app.db.GetUserID(accessToken)
if ownerID == -1 {
return ErrBadAccessToken
}
} else {
ownerID = u.ID
}
// TODO: don't make two queries
var realOwnerID sql.NullInt64
err = app.db.QueryRow("SELECT collection_id, owner_id FROM posts WHERE id = ?", friendlyID).Scan(&collID, &realOwnerID)
if err != nil {
return err
}
if !collID.Valid {
// There's no collection; simply delete the post
res, err = app.db.Exec("DELETE FROM posts WHERE id = ? AND owner_id = ?", friendlyID, ownerID)
} else {
// Post belongs to a collection; do any additional clean up
coll, err = app.db.GetCollectionBy("id = ?", collID.Int64)
if err != nil {
log.Error("Unable to get collection: %v", err)
return err
}
if app.cfg.App.Federation {
// First fetch full post for federation
pp, err = app.db.GetOwnedPost(friendlyID, ownerID)
if err != nil {
log.Error("Unable to get owned post: %v", err)
return err
}
collObj := &CollectionObj{Collection: *coll}
pp.Collection = collObj
}
t, err = app.db.Begin()
if err != nil {
log.Error("No begin: %v", err)
return err
}
res, err = t.Exec("DELETE FROM posts WHERE id = ? AND owner_id = ?", friendlyID, ownerID)
}
} else {
return impart.HTTPError{http.StatusBadRequest, "No authenticated user or post token given."}
}
if err != nil {
return err
}
affected, err := res.RowsAffected()
if err != nil {
if t != nil {
t.Rollback()
log.Error("Rows affected err! Rolling back")
}
return err
} else if affected == 0 {
if t != nil {
t.Rollback()
log.Error("No rows affected! Rolling back")
}
return impart.HTTPError{http.StatusForbidden, "Post not found, or you're not the owner."}
}
if t != nil {
t.Commit()
}
if coll != nil && !app.cfg.App.Private && app.cfg.App.Federation {
go deleteFederatedPost(app, pp, collID.Int64)
}
return impart.HTTPError{Status: http.StatusNoContent}
}
// addPost associates a post with the authenticated user.
func addPost(app *App, w http.ResponseWriter, r *http.Request) error {
var ownerID int64
// Authenticate user
at := r.Header.Get("Authorization")
if at != "" {
ownerID = app.db.GetUserID(at)
if ownerID == -1 {
return ErrBadAccessToken
}
} else {
u := getUserSession(app, r)
if u == nil {
return ErrNotLoggedIn
}
ownerID = u.ID
}
+ suspended, err := app.db.IsUserSuspended(ownerID)
+ if err != nil {
+ log.Error("add post: %v", err)
+ return ErrInternalGeneral
+ }
+ if suspended {
+ return ErrUserSuspended
+ }
+
// Parse claimed posts in format:
// [{"id": "...", "token": "..."}]
var claims *[]ClaimPostRequest
decoder := json.NewDecoder(r.Body)
- err := decoder.Decode(&claims)
+ err = decoder.Decode(&claims)
if err != nil {
return ErrBadJSONArray
}
vars := mux.Vars(r)
collAlias := vars["alias"]
// Update all given posts
res, err := app.db.ClaimPosts(app.cfg, ownerID, collAlias, claims)
if err != nil {
return err
}
if !app.cfg.App.Private && app.cfg.App.Federation {
for _, pRes := range *res {
if pRes.Code != http.StatusOK {
continue
}
if !pRes.Post.Created.After(time.Now()) {
pRes.Post.Collection.hostName = app.cfg.App.Host
go federatePost(app, pRes.Post, pRes.Post.Collection.ID, false)
}
}
}
return impart.WriteSuccess(w, res, http.StatusOK)
}
func dispersePost(app *App, w http.ResponseWriter, r *http.Request) error {
var ownerID int64
// Authenticate user
at := r.Header.Get("Authorization")
if at != "" {
ownerID = app.db.GetUserID(at)
if ownerID == -1 {
return ErrBadAccessToken
}
} else {
u := getUserSession(app, r)
if u == nil {
return ErrNotLoggedIn
}
ownerID = u.ID
}
// Parse posts in format:
// ["..."]
var postIDs []string
decoder := json.NewDecoder(r.Body)
err := decoder.Decode(&postIDs)
if err != nil {
return ErrBadJSONArray
}
// Update all given posts
res, err := app.db.DispersePosts(ownerID, postIDs)
if err != nil {
return err
}
return impart.WriteSuccess(w, res, http.StatusOK)
}
type (
PinPostResult struct {
ID string `json:"id,omitempty"`
Code int `json:"code,omitempty"`
ErrorMessage string `json:"error_msg,omitempty"`
}
)
// pinPost pins a post to a blog
func pinPost(app *App, w http.ResponseWriter, r *http.Request) error {
var userID int64
// Authenticate user
at := r.Header.Get("Authorization")
if at != "" {
userID = app.db.GetUserID(at)
if userID == -1 {
return ErrBadAccessToken
}
} else {
u := getUserSession(app, r)
if u == nil {
return ErrNotLoggedIn
}
userID = u.ID
}
+ suspended, err := app.db.IsUserSuspended(userID)
+ if err != nil {
+ log.Error("pin post: %v", err)
+ return ErrInternalGeneral
+ }
+ if suspended {
+ return ErrUserSuspended
+ }
+
// Parse request
var posts []struct {
ID string `json:"id"`
Position int64 `json:"position"`
}
decoder := json.NewDecoder(r.Body)
- err := decoder.Decode(&posts)
+ err = decoder.Decode(&posts)
if err != nil {
return ErrBadJSONArray
}
// Validate data
vars := mux.Vars(r)
collAlias := vars["alias"]
coll, err := app.db.GetCollection(collAlias)
if err != nil {
return err
}
if coll.OwnerID != userID {
return ErrForbiddenCollection
}
// Do (un)pinning
isPinning := r.URL.Path[strings.LastIndex(r.URL.Path, "/"):] == "/pin"
res := []PinPostResult{}
for _, p := range posts {
err = app.db.UpdatePostPinState(isPinning, p.ID, coll.ID, userID, p.Position)
ppr := PinPostResult{ID: p.ID}
if err != nil {
ppr.Code = http.StatusInternalServerError
// TODO: set error messsage
} else {
ppr.Code = http.StatusOK
}
res = append(res, ppr)
}
return impart.WriteSuccess(w, res, http.StatusOK)
}
func fetchPost(app *App, w http.ResponseWriter, r *http.Request) error {
var collID int64
+ var ownerID int64
var coll *Collection
var err error
vars := mux.Vars(r)
if collAlias := vars["alias"]; collAlias != "" {
// Fetch collection information, since an alias is provided
coll, err = app.db.GetCollection(collAlias)
if err != nil {
return err
}
coll.hostName = app.cfg.App.Host
_, err = apiCheckCollectionPermissions(app, r, coll)
if err != nil {
return err
}
collID = coll.ID
+ ownerID = coll.OwnerID
}
p, err := app.db.GetPost(vars["post"], collID)
if err != nil {
return err
}
+ suspended, err := app.db.IsUserSuspended(ownerID)
+ if err != nil {
+ log.Error("fetch post: %v", err)
+ return ErrInternalGeneral
+ }
+
+ if suspended {
+ return ErrPostNotFound
+ }
p.extractData()
accept := r.Header.Get("Accept")
if strings.Contains(accept, "application/activity+json") {
// Fetch information about the collection this belongs to
if coll == nil && p.CollectionID.Valid {
coll, err = app.db.GetCollectionByID(p.CollectionID.Int64)
if err != nil {
return err
}
}
if coll == nil {
// This is a draft post; 404 for now
// TODO: return ActivityObject
return impart.HTTPError{http.StatusNotFound, ""}
}
p.Collection = &CollectionObj{Collection: *coll}
- po := p.ActivityObject()
+ po := p.ActivityObject(app.cfg)
po.Context = []interface{}{activitystreams.Namespace}
return impart.RenderActivityJSON(w, po, http.StatusOK)
}
return impart.WriteSuccess(w, p, http.StatusOK)
}
func fetchPostProperty(app *App, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
p, err := app.db.GetPostProperty(vars["post"], 0, vars["property"])
if err != nil {
return err
}
return impart.WriteSuccess(w, p, http.StatusOK)
}
func (p *Post) processPost() PublicPost {
res := &PublicPost{Post: p, Views: 0}
res.Views = p.ViewCount
// TODO: move to own function
loc := monday.FuzzyLocale(p.Language.String)
res.DisplayDate = monday.Format(p.Created, monday.LongFormatsByLocale[loc], loc)
return *res
}
-func (p *PublicPost) CanonicalURL() string {
+func (p *PublicPost) CanonicalURL(hostName string) string {
if p.Collection == nil || p.Collection.Alias == "" {
- return p.Collection.hostName + "/" + p.ID
+ return hostName + "/" + p.ID
}
return p.Collection.CanonicalURL() + p.Slug.String
}
-func (p *PublicPost) ActivityObject() *activitystreams.Object {
+func (p *PublicPost) ActivityObject(cfg *config.Config) *activitystreams.Object {
o := activitystreams.NewArticleObject()
o.ID = p.Collection.FederatedAPIBase() + "api/posts/" + p.ID
o.Published = p.Created
- o.URL = p.CanonicalURL()
+ o.URL = p.CanonicalURL(cfg.App.Host)
o.AttributedTo = p.Collection.FederatedAccount()
o.CC = []string{
p.Collection.FederatedAccount() + "/followers",
}
o.Name = p.DisplayTitle()
if p.HTMLContent == template.HTML("") {
- p.formatContent(false)
+ p.formatContent(cfg, false)
}
o.Content = string(p.HTMLContent)
if p.Language.Valid {
o.ContentMap = map[string]string{
p.Language.String: string(p.HTMLContent),
}
}
if len(p.Tags) == 0 {
o.Tag = []activitystreams.Tag{}
} else {
var tagBaseURL string
if isSingleUser {
tagBaseURL = p.Collection.CanonicalURL() + "tag:"
} else {
- tagBaseURL = fmt.Sprintf("%s/%s/tag:", p.Collection.hostName, p.Collection.Alias)
+ if cfg.App.Chorus {
+ tagBaseURL = fmt.Sprintf("%s/read/t/", p.Collection.hostName)
+ } else {
+ tagBaseURL = fmt.Sprintf("%s/%s/tag:", p.Collection.hostName, p.Collection.Alias)
+ }
}
for _, t := range p.Tags {
o.Tag = append(o.Tag, activitystreams.Tag{
Type: activitystreams.TagHashtag,
HRef: tagBaseURL + t,
Name: "#" + t,
})
}
}
return o
}
// TODO: merge this into getSlugFromPost or phase it out
func getSlug(title, lang string) string {
return getSlugFromPost("", title, lang)
}
func getSlugFromPost(title, body, lang string) string {
if title == "" {
title = postTitle(body, body)
}
title = parse.PostLede(title, false)
// Truncate lede if needed
title, _ = parse.TruncToWord(title, 80)
var s string
if lang != "" && len(lang) == 2 {
s = slug.MakeLang(title, lang)
} else {
s = slug.Make(title)
}
// Transliteration may cause the slug to expand past the limit, so truncate again
s, _ = parse.TruncToWord(s, 80)
return strings.TrimFunc(s, func(r rune) bool {
// TruncToWord doesn't respect words in a slug, since spaces are replaced
// with hyphens. So remove any trailing hyphens.
return r == '-'
})
}
// isFontValid returns whether or not the submitted post's appearance is valid.
func (p *SubmittedPost) isFontValid() bool {
validFonts := map[string]bool{
"norm": true,
"sans": true,
"mono": true,
"wrap": true,
"code": true,
}
_, valid := validFonts[p.Font]
return valid
}
func getRawPost(app *App, friendlyID string) *RawPost {
var content, font, title string
var isRTL sql.NullBool
var lang sql.NullString
var ownerID sql.NullInt64
var created time.Time
err := app.db.QueryRow("SELECT title, content, text_appearance, language, rtl, created, owner_id FROM posts WHERE id = ?", friendlyID).Scan(&title, &content, &font, &lang, &isRTL, &created, &ownerID)
switch {
case err == sql.ErrNoRows:
return &RawPost{Content: "", Found: false, Gone: false}
case err != nil:
return &RawPost{Content: "", Found: true, Gone: false}
}
return &RawPost{Title: title, Content: content, Font: font, Created: created, IsRTL: isRTL, Language: lang, OwnerID: ownerID.Int64, Found: true, Gone: content == ""}
}
// TODO; return a Post!
func getRawCollectionPost(app *App, slug, collAlias string) *RawPost {
var id, title, content, font string
var isRTL sql.NullBool
var lang sql.NullString
var created time.Time
var ownerID null.Int
var views int64
var err error
if app.cfg.App.SingleUser {
err = app.db.QueryRow("SELECT id, title, content, text_appearance, language, rtl, view_count, created, owner_id FROM posts WHERE slug = ? AND collection_id = 1", slug).Scan(&id, &title, &content, &font, &lang, &isRTL, &views, &created, &ownerID)
} else {
err = app.db.QueryRow("SELECT id, title, content, text_appearance, language, rtl, view_count, created, owner_id FROM posts WHERE slug = ? AND collection_id = (SELECT id FROM collections WHERE alias = ?)", slug, collAlias).Scan(&id, &title, &content, &font, &lang, &isRTL, &views, &created, &ownerID)
}
switch {
case err == sql.ErrNoRows:
return &RawPost{Content: "", Found: false, Gone: false}
case err != nil:
return &RawPost{Content: "", Found: true, Gone: false}
}
return &RawPost{
Id: id,
Slug: slug,
Title: title,
Content: content,
Font: font,
Created: created,
IsRTL: isRTL,
Language: lang,
OwnerID: ownerID.Int64,
Found: true,
Gone: content == "",
Views: views,
}
}
func isRaw(r *http.Request) bool {
vars := mux.Vars(r)
slug := vars["slug"]
// NOTE: until this is done better, be sure to keep this in parity with
// isRaw in viewCollectionPost() and handleViewPost()
isJSON := strings.HasSuffix(slug, ".json")
isXML := strings.HasSuffix(slug, ".xml")
isMarkdown := strings.HasSuffix(slug, ".md")
return strings.HasSuffix(slug, ".txt") || isJSON || isXML || isMarkdown
}
func viewCollectionPost(app *App, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
slug := vars["slug"]
// NOTE: until this is done better, be sure to keep this in parity with
// isRaw() and handleViewPost()
isJSON := strings.HasSuffix(slug, ".json")
isXML := strings.HasSuffix(slug, ".xml")
isMarkdown := strings.HasSuffix(slug, ".md")
isRaw := strings.HasSuffix(slug, ".txt") || isJSON || isXML || isMarkdown
cr := &collectionReq{}
err := processCollectionRequest(cr, vars, w, r)
if err != nil {
return err
}
// Check for hellbanned users
u, err := checkUserForCollection(app, cr, r, true)
if err != nil {
return err
}
// Normalize the URL, redirecting user to consistent post URL
if slug != strings.ToLower(slug) {
loc := fmt.Sprintf("/%s", strings.ToLower(slug))
if !app.cfg.App.SingleUser {
loc = "/" + cr.alias + loc
}
return impart.HTTPError{http.StatusMovedPermanently, loc}
}
// Display collection if this is a collection
var c *Collection
if app.cfg.App.SingleUser {
c, err = app.db.GetCollectionByID(1)
} else {
c, err = app.db.GetCollection(cr.alias)
}
if err != nil {
if err, ok := err.(impart.HTTPError); ok {
if err.Status == http.StatusNotFound {
// Redirect if necessary
newAlias := app.db.GetCollectionRedirect(cr.alias)
if newAlias != "" {
return impart.HTTPError{http.StatusFound, "/" + newAlias + "/" + slug}
}
}
}
return err
}
c.hostName = app.cfg.App.Host
+ suspended, err := app.db.IsUserSuspended(c.OwnerID)
+ if err != nil {
+ log.Error("view collection post: %v", err)
+ return ErrInternalGeneral
+ }
+
// Check collection permissions
if c.IsPrivate() && (u == nil || u.ID != c.OwnerID) {
return ErrPostNotFound
}
if c.IsProtected() && ((u == nil || u.ID != c.OwnerID) && !isAuthorizedForCollection(app, c.Alias, r)) {
return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "/?g=" + slug}
}
cr.isCollOwner = u != nil && c.OwnerID == u.ID
if isRaw {
slug = strings.Split(slug, ".")[0]
}
// Fetch extra data about the Collection
// TODO: refactor out this logic, shared in collection.go:fetchCollection()
coll := &CollectionObj{Collection: *c}
owner, err := app.db.GetUserByID(coll.OwnerID)
if err != nil {
// Log the error and just continue
log.Error("Error getting user for collection: %v", err)
} else {
coll.Owner = owner
}
postFound := true
p, err := app.db.GetPost(slug, coll.ID)
if err != nil {
if err == ErrCollectionPageNotFound {
postFound = false
if slug == "feed" {
// User tried to access blog feed without a trailing slash, and
// there's no post with a slug "feed"
return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "/feed/"}
}
po := &Post{
Slug: null.NewString(slug, true),
Font: "norm",
Language: zero.NewString("en", true),
RTL: zero.NewBool(false, true),
Content: `
This page is missing.
Are you sure it was ever here?`,
}
pp := po.processPost()
p = &pp
} else {
return err
}
}
p.IsOwner = owner != nil && p.OwnerID.Valid && owner.ID == p.OwnerID.Int64
p.Collection = coll
p.IsTopLevel = app.cfg.App.SingleUser
+ if !p.IsOwner && suspended {
+ return ErrPostNotFound
+ }
// Check if post has been unpublished
if p.Content == "" && p.Title.String == "" {
return impart.HTTPError{http.StatusGone, "Post was unpublished."}
}
// Serve collection post
if isRaw {
contentType := "text/plain"
if isJSON {
contentType = "application/json"
} else if isXML {
contentType = "application/xml"
} else if isMarkdown {
contentType = "text/markdown"
}
w.Header().Set("Content-Type", fmt.Sprintf("%s; charset=utf-8", contentType))
if !postFound {
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, "Post not found.")
// TODO: return error instead, so status is correctly reflected in logs
return nil
}
if isMarkdown && p.Title.String != "" {
fmt.Fprintf(w, "# %s\n\n", p.Title.String)
}
fmt.Fprint(w, p.Content)
} else if strings.Contains(r.Header.Get("Accept"), "application/activity+json") {
if !postFound {
return ErrCollectionPageNotFound
}
p.extractData()
- ap := p.ActivityObject()
+ ap := p.ActivityObject(app.cfg)
ap.Context = []interface{}{activitystreams.Namespace}
return impart.RenderActivityJSON(w, ap, http.StatusOK)
} else {
p.extractData()
p.Content = strings.Replace(p.Content, "", "", 1)
// TODO: move this to function
- p.formatContent(cr.isCollOwner)
+ p.formatContent(app.cfg, cr.isCollOwner)
tp := struct {
*PublicPost
page.StaticPage
IsOwner bool
IsPinned bool
IsCustomDomain bool
PinnedPosts *[]PublicPost
IsFound bool
+ IsAdmin bool
+ CanInvite bool
+ Suspended bool
}{
PublicPost: p,
StaticPage: pageForReq(app, r),
IsOwner: cr.isCollOwner,
IsCustomDomain: cr.isCustomDomain,
IsFound: postFound,
+ Suspended: suspended,
}
- tp.PinnedPosts, _ = app.db.GetPinnedPosts(coll)
+ tp.IsAdmin = u != nil && u.IsAdmin()
+ tp.CanInvite = canUserInvite(app.cfg, tp.IsAdmin)
+ tp.PinnedPosts, _ = app.db.GetPinnedPosts(coll, p.IsOwner)
tp.IsPinned = len(*tp.PinnedPosts) > 0 && PostsContains(tp.PinnedPosts, p)
if !postFound {
w.WriteHeader(http.StatusNotFound)
}
- if err := templates["collection-post"].ExecuteTemplate(w, "post", tp); err != nil {
+ postTmpl := "collection-post"
+ if app.cfg.App.Chorus {
+ postTmpl = "chorus-collection-post"
+ }
+ if err := templates[postTmpl].ExecuteTemplate(w, "post", tp); err != nil {
log.Error("Error in collection-post template: %v", err)
}
}
go func() {
if p.OwnerID.Valid {
// Post is owned by someone. Don't update stats if owner is viewing the post.
if u != nil && p.OwnerID.Int64 == u.ID {
return
}
}
// Update stats for non-raw post views
if !isRaw && r.Method != "HEAD" && !bots.IsBot(r.UserAgent()) {
_, err := app.db.Exec("UPDATE posts SET view_count = view_count + 1 WHERE slug = ? AND collection_id = ?", slug, coll.ID)
if err != nil {
log.Error("Unable to update posts count: %v", err)
}
}
}()
return nil
}
// TODO: move this to utils after making it more generic
func PostsContains(sl *[]PublicPost, s *PublicPost) bool {
for _, e := range *sl {
if e.ID == s.ID {
return true
}
}
return false
}
func (p *Post) extractData() {
p.Tags = tags.Extract(p.Content)
p.extractImages()
}
func (rp *RawPost) UserFacingCreated() string {
return rp.Created.Format(postMetaDateFormat)
}
func (rp *RawPost) Created8601() string {
return rp.Created.Format("2006-01-02T15:04:05Z")
}
var imageURLRegex = regexp.MustCompile(`(?i)^https?:\/\/[^ ]*\.(gif|png|jpg|jpeg|image)$`)
func (p *Post) extractImages() {
matches := extract.ExtractUrls(p.Content)
urls := map[string]bool{}
for i := range matches {
u := matches[i].Text
if !imageURLRegex.MatchString(u) {
continue
}
urls[u] = true
}
resURLs := make([]string, 0)
for k := range urls {
resURLs = append(resURLs, k)
}
p.Images = resURLs
}
diff --git a/read.go b/read.go
index 3bc91c7..d708121 100644
--- a/read.go
+++ b/read.go
@@ -1,305 +1,326 @@
/*
* Copyright © 2018-2019 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*
* WriteFreely is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, included
* in the LICENSE file in this source code package.
*/
package writefreely
import (
"database/sql"
"fmt"
+ "html/template"
+ "math"
+ "net/http"
+ "strconv"
+ "time"
+
. "github.com/gorilla/feeds"
"github.com/gorilla/mux"
stripmd "github.com/writeas/go-strip-markdown"
"github.com/writeas/impart"
"github.com/writeas/web-core/log"
"github.com/writeas/web-core/memo"
"github.com/writeas/writefreely/page"
- "html/template"
- "math"
- "net/http"
- "strconv"
- "time"
)
const (
tlFeedLimit = 100
tlAPIPageLimit = 10
tlMaxAuthorPosts = 5
tlPostsPerPage = 16
)
type localTimeline struct {
m *memo.Memo
posts *[]PublicPost
// Configuration values
postsPerPage int
}
type readPublication struct {
page.StaticPage
Posts *[]PublicPost
CurrentPage int
TotalPages int
+ SelTopic string
+ IsAdmin bool
+ CanInvite bool
+
+ // Customizable page content
+ ContentTitle string
+ Content template.HTML
}
func initLocalTimeline(app *App) {
app.timeline = &localTimeline{
postsPerPage: tlPostsPerPage,
m: memo.New(app.FetchPublicPosts, 10*time.Minute),
}
}
// satisfies memo.Func
func (app *App) FetchPublicPosts() (interface{}, error) {
// Finds all public posts and posts in a public collection published during the owner's active subscription period and within the last 3 months
rows, err := app.db.Query(`SELECT p.id, alias, c.title, p.slug, p.title, p.content, p.text_appearance, p.language, p.rtl, p.created, p.updated
FROM collections c
LEFT JOIN posts p ON p.collection_id = c.id
- WHERE c.privacy = 1 AND (p.created >= ` + app.db.dateSub(3, "month") + ` AND p.created <= ` + app.db.now() + ` AND pinned_position IS NULL)
+ LEFT JOIN users u ON u.id = p.owner_id
+ WHERE c.privacy = 1 AND (p.created >= ` + app.db.dateSub(3, "month") + ` AND p.created <= ` + app.db.now() + ` AND pinned_position IS NULL) AND u.status = 0
ORDER BY p.created DESC`)
if err != nil {
log.Error("Failed selecting from posts: %v", err)
return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve collection posts." + err.Error()}
}
defer rows.Close()
ap := map[string]uint{}
posts := []PublicPost{}
for rows.Next() {
p := &Post{}
c := &Collection{}
var alias, title sql.NullString
err = rows.Scan(&p.ID, &alias, &title, &p.Slug, &p.Title, &p.Content, &p.Font, &p.Language, &p.RTL, &p.Created, &p.Updated)
if err != nil {
log.Error("[READ] Unable to scan row, skipping: %v", err)
continue
}
c.hostName = app.cfg.App.Host
isCollectionPost := alias.Valid
if isCollectionPost {
c.Alias = alias.String
if c.Alias != "" && ap[c.Alias] == tlMaxAuthorPosts {
// Don't add post if we've hit the post-per-author limit
continue
}
c.Public = true
c.Title = title.String
}
p.extractData()
- p.HTMLContent = template.HTML(applyMarkdown([]byte(p.Content), ""))
+ p.HTMLContent = template.HTML(applyMarkdown([]byte(p.Content), "", app.cfg))
fp := p.processPost()
if isCollectionPost {
fp.Collection = &CollectionObj{Collection: *c}
}
posts = append(posts, fp)
ap[c.Alias]++
}
return posts, nil
}
func viewLocalTimelineAPI(app *App, w http.ResponseWriter, r *http.Request) error {
updateTimelineCache(app.timeline)
skip, _ := strconv.Atoi(r.FormValue("skip"))
posts := []PublicPost{}
for i := skip; i < skip+tlAPIPageLimit && i < len(*app.timeline.posts); i++ {
posts = append(posts, (*app.timeline.posts)[i])
}
return impart.WriteSuccess(w, posts, http.StatusOK)
}
func viewLocalTimeline(app *App, w http.ResponseWriter, r *http.Request) error {
if !app.cfg.App.LocalTimeline {
return impart.HTTPError{http.StatusNotFound, "Page doesn't exist."}
}
vars := mux.Vars(r)
var p int
page := 1
p, _ = strconv.Atoi(vars["page"])
if p > 0 {
page = p
}
return showLocalTimeline(app, w, r, page, vars["author"], vars["tag"])
}
func updateTimelineCache(tl *localTimeline) {
// Fetch posts if enough time has passed since last cache
if tl.posts == nil || tl.m.Invalidate() {
log.Info("[READ] Updating post cache")
var err error
var postsInterfaces interface{}
postsInterfaces, err = tl.m.Get()
if err != nil {
log.Error("[READ] Unable to cache posts: %v", err)
} else {
castPosts := postsInterfaces.([]PublicPost)
tl.posts = &castPosts
}
}
}
func showLocalTimeline(app *App, w http.ResponseWriter, r *http.Request, page int, author, tag string) error {
updateTimelineCache(app.timeline)
pl := len(*(app.timeline.posts))
ttlPages := int(math.Ceil(float64(pl) / float64(app.timeline.postsPerPage)))
start := 0
if page > 1 {
start = app.timeline.postsPerPage * (page - 1)
if start > pl {
return impart.HTTPError{http.StatusFound, fmt.Sprintf("/read/p/%d", ttlPages)}
}
}
end := app.timeline.postsPerPage * page
if end > pl {
end = pl
}
var posts []PublicPost
if author != "" {
posts = []PublicPost{}
for _, p := range *app.timeline.posts {
if author == "anonymous" {
if p.Collection == nil {
posts = append(posts, p)
}
} else if p.Collection != nil && p.Collection.Alias == author {
posts = append(posts, p)
}
}
} else if tag != "" {
posts = []PublicPost{}
for _, p := range *app.timeline.posts {
if p.HasTag(tag) {
posts = append(posts, p)
}
}
} else {
posts = *app.timeline.posts
posts = posts[start:end]
}
d := &readPublication{
- pageForReq(app, r),
- &posts,
- page,
- ttlPages,
+ StaticPage: pageForReq(app, r),
+ Posts: &posts,
+ CurrentPage: page,
+ TotalPages: ttlPages,
+ SelTopic: tag,
+ }
+ if app.cfg.App.Chorus {
+ u := getUserSession(app, r)
+ d.IsAdmin = u != nil && u.IsAdmin()
+ d.CanInvite = canUserInvite(app.cfg, d.IsAdmin)
+ }
+ c, err := getReaderSection(app)
+ if err != nil {
+ return err
}
+ d.ContentTitle = c.Title.String
+ d.Content = template.HTML(applyMarkdown([]byte(c.Content), "", app.cfg))
- err := templates["read"].ExecuteTemplate(w, "base", d)
+ err = templates["read"].ExecuteTemplate(w, "base", d)
if err != nil {
log.Error("Unable to render reader: %v", err)
fmt.Fprintf(w, ":(")
}
return nil
}
// NextPageURL provides a full URL for the next page of collection posts
func (c *readPublication) NextPageURL(n int) string {
return fmt.Sprintf("/read/p/%d", n+1)
}
// PrevPageURL provides a full URL for the previous page of collection posts,
// returning a /page/N result for pages >1
func (c *readPublication) PrevPageURL(n int) string {
if n == 2 {
// Previous page is 1; no need for /p/ prefix
return "/read"
}
return fmt.Sprintf("/read/p/%d", n-1)
}
// handlePostIDRedirect handles a route where a post ID is given and redirects
// the user to the canonical post URL.
func handlePostIDRedirect(app *App, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
postID := vars["post"]
p, err := app.db.GetPost(postID, 0)
if err != nil {
return err
}
if !p.CollectionID.Valid {
// No collection; send to normal URL
// NOTE: not handling single user blogs here since this handler is only used for the Reader
return impart.HTTPError{http.StatusFound, app.cfg.App.Host + "/" + postID + ".md"}
}
c, err := app.db.GetCollectionBy("id = ?", fmt.Sprintf("%d", p.CollectionID.Int64))
if err != nil {
return err
}
c.hostName = app.cfg.App.Host
// Retrieve collection information and send user to canonical URL
return impart.HTTPError{http.StatusFound, c.CanonicalURL() + p.Slug.String}
}
func viewLocalTimelineFeed(app *App, w http.ResponseWriter, req *http.Request) error {
if !app.cfg.App.LocalTimeline {
return impart.HTTPError{http.StatusNotFound, "Page doesn't exist."}
}
updateTimelineCache(app.timeline)
feed := &Feed{
Title: app.cfg.App.SiteName + " Reader",
Link: &Link{Href: app.cfg.App.Host},
Description: "Read the latest posts from " + app.cfg.App.SiteName + ".",
Created: time.Now(),
}
c := 0
var title, permalink, author string
for _, p := range *app.timeline.posts {
if c == tlFeedLimit {
break
}
title = p.PlainDisplayTitle()
- permalink = p.CanonicalURL()
+ permalink = p.CanonicalURL(app.cfg.App.Host)
if p.Collection != nil {
author = p.Collection.Title
} else {
author = "Anonymous"
permalink += ".md"
}
i := &Item{
Id: app.cfg.App.Host + "/read/a/" + p.ID,
Title: title,
Link: &Link{Href: permalink},
Description: "",
- Content: applyMarkdown([]byte(p.Content), ""),
+ Content: applyMarkdown([]byte(p.Content), "", app.cfg),
Author: &Author{author, ""},
Created: p.Created,
Updated: p.Updated,
}
feed.Items = append(feed.Items, i)
c++
}
rss, err := feed.ToRss()
if err != nil {
return err
}
fmt.Fprint(w, rss)
return nil
}
diff --git a/request.go b/request.go
index 4939f9c..2eb29f5 100644
--- a/request.go
+++ b/request.go
@@ -1,18 +1,22 @@
/*
* Copyright © 2018 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*
* WriteFreely is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, included
* in the LICENSE file in this source code package.
*/
package writefreely
-import "mime"
+import (
+ "mime"
+ "net/http"
+)
-func IsJSON(h string) bool {
- ct, _, _ := mime.ParseMediaType(h)
- return ct == "application/json"
+func IsJSON(r *http.Request) bool {
+ ct, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
+ accept := r.Header.Get("Accept")
+ return ct == "application/json" || accept == "application/json"
}
diff --git a/routes.go b/routes.go
index 7dcdc65..eb5422a 100644
--- a/routes.go
+++ b/routes.go
@@ -1,207 +1,210 @@
/*
* Copyright © 2018-2019 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*
* WriteFreely is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, included
* in the LICENSE file in this source code package.
*/
package writefreely
import (
"net/http"
"path/filepath"
"strings"
"github.com/gorilla/mux"
"github.com/writeas/go-webfinger"
"github.com/writeas/web-core/log"
"github.com/writefreely/go-nodeinfo"
)
// InitStaticRoutes adds routes for serving static files.
// TODO: this should just be a func, not method
func (app *App) InitStaticRoutes(r *mux.Router) {
// Handle static files
fs := http.FileServer(http.Dir(filepath.Join(app.cfg.Server.StaticParentDir, staticDir)))
app.shttp = http.NewServeMux()
app.shttp.Handle("/", fs)
r.PathPrefix("/").Handler(fs)
}
// InitRoutes adds dynamic routes for the given mux.Router.
func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
// Create handler
handler := NewWFHandler(apper)
// Set up routes
hostSubroute := apper.App().cfg.App.Host[strings.Index(apper.App().cfg.App.Host, "://")+3:]
if apper.App().cfg.App.SingleUser {
hostSubroute = "{domain}"
} else {
if strings.HasPrefix(hostSubroute, "localhost") {
hostSubroute = "localhost"
}
}
if apper.App().cfg.App.SingleUser {
log.Info("Adding %s routes (single user)...", hostSubroute)
} else {
log.Info("Adding %s routes (multi-user)...", hostSubroute)
}
// Primary app routes
write := r.PathPrefix("/").Subrouter()
// Federation endpoint configurations
wf := webfinger.Default(wfResolver{apper.App().db, apper.App().cfg})
wf.NoTLSHandler = nil
// Federation endpoints
// host-meta
write.HandleFunc("/.well-known/host-meta", handler.Web(handleViewHostMeta, UserLevelReader))
// webfinger
write.HandleFunc(webfinger.WebFingerPath, handler.LogHandlerFunc(http.HandlerFunc(wf.Webfinger)))
// nodeinfo
niCfg := nodeInfoConfig(apper.App().db, apper.App().cfg)
ni := nodeinfo.NewService(*niCfg, nodeInfoResolver{apper.App().cfg, apper.App().db})
write.HandleFunc(nodeinfo.NodeInfoPath, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfoDiscover)))
write.HandleFunc(niCfg.InfoURL, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfo)))
// Set up dyamic page handlers
// Handle auth
auth := write.PathPrefix("/api/auth/").Subrouter()
if apper.App().cfg.App.OpenRegistration {
auth.HandleFunc("/signup", handler.All(apiSignup)).Methods("POST")
}
auth.HandleFunc("/login", handler.All(login)).Methods("POST")
auth.HandleFunc("/read", handler.WebErrors(handleWebCollectionUnlock, UserLevelNone)).Methods("POST")
auth.HandleFunc("/me", handler.All(handleAPILogout)).Methods("DELETE")
// Handle logged in user sections
me := write.PathPrefix("/me").Subrouter()
me.HandleFunc("/", handler.Redirect("/me", UserLevelUser))
me.HandleFunc("/c", handler.Redirect("/me/c/", UserLevelUser)).Methods("GET")
me.HandleFunc("/c/", handler.User(viewCollections)).Methods("GET")
me.HandleFunc("/c/{collection}", handler.User(viewEditCollection)).Methods("GET")
me.HandleFunc("/c/{collection}/stats", handler.User(viewStats)).Methods("GET")
me.HandleFunc("/posts", handler.Redirect("/me/posts/", UserLevelUser)).Methods("GET")
me.HandleFunc("/posts/", handler.User(viewArticles)).Methods("GET")
me.HandleFunc("/posts/export.csv", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET")
me.HandleFunc("/posts/export.zip", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET")
me.HandleFunc("/posts/export.json", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET")
me.HandleFunc("/export", handler.User(viewExportOptions)).Methods("GET")
me.HandleFunc("/export.json", handler.Download(viewExportFull, UserLevelUser)).Methods("GET")
me.HandleFunc("/import", handler.User(viewImport)).Methods("GET")
me.HandleFunc("/settings", handler.User(viewSettings)).Methods("GET")
me.HandleFunc("/invites", handler.User(handleViewUserInvites)).Methods("GET")
me.HandleFunc("/logout", handler.Web(viewLogout, UserLevelNone)).Methods("GET")
write.HandleFunc("/api/me", handler.All(viewMeAPI)).Methods("GET")
apiMe := write.PathPrefix("/api/me/").Subrouter()
apiMe.HandleFunc("/", handler.All(viewMeAPI)).Methods("GET")
apiMe.HandleFunc("/posts", handler.UserAPI(viewMyPostsAPI)).Methods("GET")
apiMe.HandleFunc("/collections", handler.UserAPI(viewMyCollectionsAPI)).Methods("GET")
apiMe.HandleFunc("/password", handler.All(updatePassphrase)).Methods("POST")
apiMe.HandleFunc("/self", handler.All(updateSettings)).Methods("POST")
apiMe.HandleFunc("/invites", handler.User(handleCreateUserInvite)).Methods("POST")
apiMe.HandleFunc("/import", handler.User(handleImport)).Methods("POST")
// Sign up validation
write.HandleFunc("/api/alias", handler.All(handleUsernameCheck)).Methods("POST")
// Handle collections
write.HandleFunc("/api/collections", handler.All(newCollection)).Methods("POST")
apiColls := write.PathPrefix("/api/collections/").Subrouter()
apiColls.HandleFunc("/{alias:[0-9a-zA-Z\\-]+}", handler.AllReader(fetchCollection)).Methods("GET")
apiColls.HandleFunc("/{alias:[0-9a-zA-Z\\-]+}", handler.All(existingCollection)).Methods("POST", "DELETE")
apiColls.HandleFunc("/{alias}/posts", handler.AllReader(fetchCollectionPosts)).Methods("GET")
apiColls.HandleFunc("/{alias}/posts", handler.All(newPost)).Methods("POST")
apiColls.HandleFunc("/{alias}/posts/{post}", handler.AllReader(fetchPost)).Methods("GET")
apiColls.HandleFunc("/{alias}/posts/{post:[a-zA-Z0-9]{10}}", handler.All(existingPost)).Methods("POST")
apiColls.HandleFunc("/{alias}/posts/{post}/{property}", handler.AllReader(fetchPostProperty)).Methods("GET")
apiColls.HandleFunc("/{alias}/collect", handler.All(addPost)).Methods("POST")
apiColls.HandleFunc("/{alias}/pin", handler.All(pinPost)).Methods("POST")
apiColls.HandleFunc("/{alias}/unpin", handler.All(pinPost)).Methods("POST")
apiColls.HandleFunc("/{alias}/inbox", handler.All(handleFetchCollectionInbox)).Methods("POST")
apiColls.HandleFunc("/{alias}/outbox", handler.AllReader(handleFetchCollectionOutbox)).Methods("GET")
apiColls.HandleFunc("/{alias}/following", handler.AllReader(handleFetchCollectionFollowing)).Methods("GET")
apiColls.HandleFunc("/{alias}/followers", handler.AllReader(handleFetchCollectionFollowers)).Methods("GET")
// Handle posts
write.HandleFunc("/api/posts", handler.All(newPost)).Methods("POST")
posts := write.PathPrefix("/api/posts/").Subrouter()
posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}", handler.AllReader(fetchPost)).Methods("GET")
posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}", handler.All(existingPost)).Methods("POST", "PUT")
posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}", handler.All(deletePost)).Methods("DELETE")
posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}/{property}", handler.AllReader(fetchPostProperty)).Methods("GET")
posts.HandleFunc("/claim", handler.All(addPost)).Methods("POST")
posts.HandleFunc("/disperse", handler.All(dispersePost)).Methods("POST")
write.HandleFunc("/auth/signup", handler.Web(handleWebSignup, UserLevelNoneRequired)).Methods("POST")
write.HandleFunc("/auth/login", handler.Web(webLogin, UserLevelNoneRequired)).Methods("POST")
write.HandleFunc("/admin", handler.Admin(handleViewAdminDash)).Methods("GET")
write.HandleFunc("/admin/users", handler.Admin(handleViewAdminUsers)).Methods("GET")
write.HandleFunc("/admin/user/{username}", handler.Admin(handleViewAdminUser)).Methods("GET")
+ write.HandleFunc("/admin/user/{username}/status", handler.Admin(handleAdminToggleUserStatus)).Methods("POST")
+ write.HandleFunc("/admin/user/{username}/passphrase", handler.Admin(handleAdminResetUserPass)).Methods("POST")
write.HandleFunc("/admin/pages", handler.Admin(handleViewAdminPages)).Methods("GET")
write.HandleFunc("/admin/page/{slug}", handler.Admin(handleViewAdminPage)).Methods("GET")
write.HandleFunc("/admin/update/config", handler.AdminApper(handleAdminUpdateConfig)).Methods("POST")
write.HandleFunc("/admin/update/{page}", handler.Admin(handleAdminUpdateSite)).Methods("POST")
// Handle special pages first
write.HandleFunc("/login", handler.Web(viewLogin, UserLevelNoneRequired))
- write.HandleFunc("/invite/{code}", handler.Web(handleViewInvite, UserLevelNoneRequired)).Methods("GET")
+ write.HandleFunc("/signup", handler.Web(handleViewLanding, UserLevelNoneRequired))
+ write.HandleFunc("/invite/{code}", handler.Web(handleViewInvite, UserLevelOptional)).Methods("GET")
// TODO: show a reader-specific 404 page if the function is disabled
write.HandleFunc("/read", handler.Web(viewLocalTimeline, UserLevelReader))
RouteRead(handler, UserLevelReader, write.PathPrefix("/read").Subrouter())
draftEditPrefix := ""
if apper.App().cfg.App.SingleUser {
draftEditPrefix = "/d"
write.HandleFunc("/me/new", handler.Web(handleViewPad, UserLevelOptional)).Methods("GET")
} else {
write.HandleFunc("/new", handler.Web(handleViewPad, UserLevelOptional)).Methods("GET")
}
// All the existing stuff
write.HandleFunc(draftEditPrefix+"/{action}/edit", handler.Web(handleViewPad, UserLevelOptional)).Methods("GET")
write.HandleFunc(draftEditPrefix+"/{action}/meta", handler.Web(handleViewMeta, UserLevelOptional)).Methods("GET")
// Collections
if apper.App().cfg.App.SingleUser {
RouteCollections(handler, write.PathPrefix("/").Subrouter())
} else {
write.HandleFunc("/{prefix:[@~$!\\-+]}{collection}", handler.Web(handleViewCollection, UserLevelReader))
write.HandleFunc("/{collection}/", handler.Web(handleViewCollection, UserLevelReader))
RouteCollections(handler, write.PathPrefix("/{prefix:[@~$!\\-+]?}{collection}").Subrouter())
// Posts
}
write.HandleFunc(draftEditPrefix+"/{post}", handler.Web(handleViewPost, UserLevelOptional))
write.HandleFunc("/", handler.Web(handleViewHome, UserLevelOptional))
return r
}
func RouteCollections(handler *Handler, r *mux.Router) {
r.HandleFunc("/page/{page:[0-9]+}", handler.Web(handleViewCollection, UserLevelReader))
r.HandleFunc("/tag:{tag}", handler.Web(handleViewCollectionTag, UserLevelReader))
r.HandleFunc("/tag:{tag}/feed/", handler.Web(ViewFeed, UserLevelReader))
r.HandleFunc("/tags/{tag}", handler.Web(handleViewCollectionTag, UserLevelReader))
r.HandleFunc("/sitemap.xml", handler.AllReader(handleViewSitemap))
r.HandleFunc("/feed/", handler.AllReader(ViewFeed))
r.HandleFunc("/{slug}", handler.CollectionPostOrStatic)
r.HandleFunc("/{slug}/edit", handler.Web(handleViewPad, UserLevelUser))
r.HandleFunc("/{slug}/edit/meta", handler.Web(handleViewMeta, UserLevelUser))
r.HandleFunc("/{slug}/", handler.Web(handleCollectionPostRedirect, UserLevelReader)).Methods("GET")
}
func RouteRead(handler *Handler, readPerm UserLevelFunc, r *mux.Router) {
r.HandleFunc("/api/posts", handler.Web(viewLocalTimelineAPI, readPerm))
r.HandleFunc("/p/{page}", handler.Web(viewLocalTimeline, readPerm))
r.HandleFunc("/feed/", handler.Web(viewLocalTimelineFeed, readPerm))
r.HandleFunc("/t/{tag}", handler.Web(viewLocalTimeline, readPerm))
r.HandleFunc("/a/{post}", handler.Web(handlePostIDRedirect, readPerm))
r.HandleFunc("/{author}", handler.Web(viewLocalTimeline, readPerm))
r.HandleFunc("/", handler.Web(viewLocalTimeline, readPerm))
}
diff --git a/sitemap.go b/sitemap.go
index 4dfd953..00e148f 100644
--- a/sitemap.go
+++ b/sitemap.go
@@ -1,109 +1,109 @@
/*
* Copyright © 2018-2019 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*
* WriteFreely is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, included
* in the LICENSE file in this source code package.
*/
package writefreely
import (
"fmt"
"net/http"
"time"
"github.com/gorilla/mux"
"github.com/ikeikeikeike/go-sitemap-generator/v2/stm"
"github.com/writeas/web-core/log"
)
func buildSitemap(host, alias string) *stm.Sitemap {
sm := stm.NewSitemap(0)
sm.SetDefaultHost(host)
if alias != "/" {
sm.SetSitemapsPath(alias)
}
sm.Create()
// Note: Do not call `sm.Finalize()` because it flushes
// the underlying datastructure from memory to disk.
return sm
}
func handleViewSitemap(app *App, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
// Determine canonical blog URL
alias := vars["collection"]
subdomain := vars["subdomain"]
isSubdomain := subdomain != ""
if isSubdomain {
alias = subdomain
}
host := fmt.Sprintf("%s/%s/", app.cfg.App.Host, alias)
var c *Collection
var err error
pre := "/"
if app.cfg.App.SingleUser {
c, err = app.db.GetCollectionByID(1)
} else {
c, err = app.db.GetCollection(alias)
}
if err != nil {
return err
}
c.hostName = app.cfg.App.Host
if !isSubdomain {
pre += alias + "/"
}
host = c.CanonicalURL()
sm := buildSitemap(host, pre)
- posts, err := app.db.GetPosts(c, 0, false, false, false)
+ posts, err := app.db.GetPosts(app.cfg, c, 0, false, false, false)
if err != nil {
log.Error("Error getting posts: %v", err)
return err
}
lastSiteMod := time.Now()
for i, p := range *posts {
if i == 0 {
lastSiteMod = p.Updated
}
u := stm.URL{
{"loc", p.Slug.String},
{"changefreq", "weekly"},
{"mobile", true},
{"lastmod", p.Updated},
}
if len(p.Images) > 0 {
imgs := []stm.URL{}
for _, i := range p.Images {
imgs = append(imgs, stm.URL{
{"loc", i},
{"title", ""},
})
}
u = append(u, []interface{}{"image", imgs})
}
sm.Add(u)
}
// Add top URL
sm.Add(stm.URL{
{"loc", pre},
{"changefreq", "daily"},
{"priority", "1.0"},
{"lastmod", lastSiteMod},
})
w.Write(sm.XMLContent())
return nil
}
diff --git a/templates.go b/templates.go
index 7a45c45..968845d 100644
--- a/templates.go
+++ b/templates.go
@@ -1,194 +1,201 @@
/*
* Copyright © 2018 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*
* WriteFreely is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, included
* in the LICENSE file in this source code package.
*/
package writefreely
import (
- "github.com/dustin/go-humanize"
- "github.com/writeas/web-core/l10n"
- "github.com/writeas/web-core/log"
- "github.com/writeas/writefreely/config"
"html/template"
"io"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"strings"
+
+ "github.com/dustin/go-humanize"
+ "github.com/writeas/web-core/l10n"
+ "github.com/writeas/web-core/log"
+ "github.com/writeas/writefreely/config"
)
var (
templates = map[string]*template.Template{}
pages = map[string]*template.Template{}
userPages = map[string]*template.Template{}
funcMap = template.FuncMap{
"largeNumFmt": largeNumFmt,
"pluralize": pluralize,
"isRTL": isRTL,
"isLTR": isLTR,
"localstr": localStr,
"localhtml": localHTML,
"tolower": strings.ToLower,
}
)
const (
templatesDir = "templates"
pagesDir = "pages"
)
func showUserPage(w http.ResponseWriter, name string, obj interface{}) {
if obj == nil {
log.Error("showUserPage: data is nil!")
return
}
if err := userPages[filepath.Join("user", name+".tmpl")].ExecuteTemplate(w, name, obj); err != nil {
log.Error("Error parsing %s: %v", name, err)
}
}
func initTemplate(parentDir, name string) {
if debugging {
log.Info(" " + filepath.Join(parentDir, templatesDir, name+".tmpl"))
}
files := []string{
filepath.Join(parentDir, templatesDir, name+".tmpl"),
filepath.Join(parentDir, templatesDir, "include", "footer.tmpl"),
filepath.Join(parentDir, templatesDir, "base.tmpl"),
+ filepath.Join(parentDir, templatesDir, "user", "include", "suspended.tmpl"),
}
- if name == "collection" || name == "collection-tags" {
+ if name == "collection" || name == "collection-tags" || name == "chorus-collection" {
// These pages list out collection posts, so we also parse templatesDir + "include/posts.tmpl"
files = append(files, filepath.Join(parentDir, templatesDir, "include", "posts.tmpl"))
}
- if name == "collection" || name == "collection-tags" || name == "collection-post" || name == "post" {
+ if name == "chorus-collection" || name == "chorus-collection-post" {
+ files = append(files, filepath.Join(parentDir, templatesDir, "user", "include", "header.tmpl"))
+ }
+ if name == "collection" || name == "collection-tags" || name == "collection-post" || name == "post" || name == "chorus-collection" || name == "chorus-collection-post" {
files = append(files, filepath.Join(parentDir, templatesDir, "include", "post-render.tmpl"))
}
templates[name] = template.Must(template.New("").Funcs(funcMap).ParseFiles(files...))
}
func initPage(parentDir, path, key string) {
if debugging {
log.Info(" [%s] %s", key, path)
}
pages[key] = template.Must(template.New("").Funcs(funcMap).ParseFiles(
path,
filepath.Join(parentDir, templatesDir, "include", "footer.tmpl"),
filepath.Join(parentDir, templatesDir, "base.tmpl"),
+ filepath.Join(parentDir, templatesDir, "user", "include", "suspended.tmpl"),
))
}
func initUserPage(parentDir, path, key string) {
if debugging {
log.Info(" [%s] %s", key, path)
}
userPages[key] = template.Must(template.New(key).Funcs(funcMap).ParseFiles(
path,
filepath.Join(parentDir, templatesDir, "user", "include", "header.tmpl"),
filepath.Join(parentDir, templatesDir, "user", "include", "footer.tmpl"),
+ filepath.Join(parentDir, templatesDir, "user", "include", "suspended.tmpl"),
))
}
// InitTemplates loads all template files from the configured parent dir.
func InitTemplates(cfg *config.Config) error {
log.Info("Loading templates...")
tmplFiles, err := ioutil.ReadDir(filepath.Join(cfg.Server.TemplatesParentDir, templatesDir))
if err != nil {
return err
}
for _, f := range tmplFiles {
if !f.IsDir() && !strings.HasPrefix(f.Name(), ".") {
parts := strings.Split(f.Name(), ".")
key := parts[0]
initTemplate(cfg.Server.TemplatesParentDir, key)
}
}
log.Info("Loading pages...")
// Initialize all static pages that use the base template
filepath.Walk(filepath.Join(cfg.Server.PagesParentDir, pagesDir), func(path string, i os.FileInfo, err error) error {
if !i.IsDir() && !strings.HasPrefix(i.Name(), ".") {
key := i.Name()
initPage(cfg.Server.PagesParentDir, path, key)
}
return nil
})
log.Info("Loading user pages...")
// Initialize all user pages that use base templates
filepath.Walk(filepath.Join(cfg.Server.TemplatesParentDir, templatesDir, "user"), func(path string, f os.FileInfo, err error) error {
if !f.IsDir() && !strings.HasPrefix(f.Name(), ".") {
corePath := path
if cfg.Server.TemplatesParentDir != "" {
corePath = corePath[len(cfg.Server.TemplatesParentDir)+1:]
}
parts := strings.Split(corePath, string(filepath.Separator))
key := f.Name()
if len(parts) > 2 {
key = filepath.Join(parts[1], f.Name())
}
initUserPage(cfg.Server.TemplatesParentDir, path, key)
}
return nil
})
return nil
}
// renderPage retrieves the given template and renders it to the given io.Writer.
// If something goes wrong, the error is logged and returned.
func renderPage(w io.Writer, tmpl string, data interface{}) error {
err := pages[tmpl].ExecuteTemplate(w, "base", data)
if err != nil {
log.Error("%v", err)
}
return err
}
func largeNumFmt(n int64) string {
return humanize.Comma(n)
}
func pluralize(singular, plural string, n int64) string {
if n == 1 {
return singular
}
return plural
}
func isRTL(d string) bool {
return d == "rtl"
}
func isLTR(d string) bool {
return d == "ltr" || d == "auto"
}
func localStr(term, lang string) string {
s := l10n.Strings(lang)[term]
if s == "" {
s = l10n.Strings("")[term]
}
return s
}
func localHTML(term, lang string) template.HTML {
s := l10n.Strings(lang)[term]
if s == "" {
s = l10n.Strings("")[term]
}
s = strings.Replace(s, "write.as", "
writefreely ", 1)
return template.HTML(s)
}
diff --git a/templates/pad.tmpl b/templates/bare.tmpl
similarity index 52%
copy from templates/pad.tmpl
copy to templates/bare.tmpl
index 914d921..a4194c9 100644
--- a/templates/pad.tmpl
+++ b/templates/bare.tmpl
@@ -1,363 +1,235 @@
{{define "pad"}}
{{if .Editing}}Editing {{if .Post.Title}}{{.Post.Title}}{{else}}{{.Post.Id}}{{end}}{{else}}New Post{{end}} — {{.SiteName}}
-
{{end}}
diff --git a/templates/base.tmpl b/templates/base.tmpl
index 775dac9..3826917 100644
--- a/templates/base.tmpl
+++ b/templates/base.tmpl
@@ -1,57 +1,92 @@
{{define "base"}}
{{ template "head" . }}
-
+ {{ if .Chorus }}
+
+ {{ else }}
+
+ {{ end }}
{{if not .SingleUser}}
+ {{if .Username}}
+
+
+
+ {{end}}
- About
- {{if and (and (not .SingleUser) .LocalTimeline) .CanViewReader}}Reader {{end}}
- {{if and (not .SingleUser) (not .Username)}}Log in {{end}}
+ {{ if and .SimpleNav (not .SingleUser) }}
+ {{if and (and .LocalTimeline .CanViewReader) .Chorus}}Home {{end}}
+ {{ end }}
+ {{if or .Chorus (not .Username)}}About {{end}}
+ {{ if not .SingleUser }}
+ {{ if .Username }}
+ {{if or (not .Chorus) (gt .MaxBlogs 1)}}Blogs {{end}}
+ {{if and (and .Chorus (eq .MaxBlogs 1)) .Username}}My Posts {{end}}
+ {{if not .DisableDrafts}}Drafts {{end}}
+ {{ end }}
+ {{if and (and .LocalTimeline .CanViewReader) (not .Chorus)}}Reader {{end}}
+ {{if and (and (and .Chorus .OpenRegistration) (not .Username)) (or (not .Private) (ne .Landing ""))}}Sign up {{end}}
+ {{if not .Username}}Log in {{else if .SimpleNav}}Log out {{end}}
+ {{ end }}
+ {{if .Chorus}}{{if .Username}}{{end}}
+
+ {{end}}
{{end}}
{{ template "content" . }}
{{ template "footer" . }}
{{if not .JSDisabled}}
{{else}}
{{if .WebFonts}}
{{end}}
{{end}}
{{end}}
{{define "body-attrs"}}{{end}}
diff --git a/templates/collection-post.tmpl b/templates/chorus-collection-post.tmpl
similarity index 67%
copy from templates/collection-post.tmpl
copy to templates/chorus-collection-post.tmpl
index 7075226..b9df8ad 100644
--- a/templates/collection-post.tmpl
+++ b/templates/chorus-collection-post.tmpl
@@ -1,130 +1,153 @@
{{define "post"}}
{{.PlainDisplayTitle}} {{localhtml "title dash" .Language.String}} {{.Collection.DisplayTitle}}
- {{ if .IsFound }}
-
+
{{if gt .Views 1}}
{{end}}
{{if gt (len .Images) 0}}
{{else}}
{{end}}
-
+
{{range .Images}}
{{else}}
{{end}}
- {{ end }}
{{if .Collection.StyleSheet}}{{end}}
+
{{if .Collection.RenderMathJax}}
{{template "mathjax" . }}
{{end}}
{{template "highlighting" .}}
-
-
-
{{if .IsScheduled}}Scheduled
{{end}}{{if .Title.String}}{{.FormattedDisplayTitle}} {{end}}{{.HTMLContent}}
-
- {{ if .Collection.ShowFooterBranding }}
-
+
+
{{localhtml "published with write.as" .Language.String}}
+
{{ end }}
{{if .Collection.CanShowScript}}
{{range .Collection.ExternalScripts}}{{end}}
{{if .Collection.Script}}{{end}}
{{end}}
{{end}}
diff --git a/templates/collection.tmpl b/templates/chorus-collection.tmpl
similarity index 84%
copy from templates/collection.tmpl
copy to templates/chorus-collection.tmpl
index 6623a2e..8250287 100644
--- a/templates/collection.tmpl
+++ b/templates/chorus-collection.tmpl
@@ -1,229 +1,233 @@
{{define "collection"}}
{{.DisplayTitle}}{{if not .SingleUser}} — {{.SiteName}}{{end}}
{{if gt .CurrentPage 1}}
{{end}}
{{if lt .CurrentPage .TotalPages}}
{{end}}
{{if not .IsPrivate}}
{{end}}
{{if .StyleSheet}}{{end}}
+
{{if .RenderMathJax}}
{{template "mathjax" .}}
{{end}}
{{template "highlighting" . }}
- {{if or .IsOwner .SingleUser}}
{{end}}
+ {{template "user-navigation" .}}
+ {{if .Suspended}}
+ {{template "user-suspended"}}
+ {{end}}
-
+
{{if .Description}}{{.Description}}
{{end}}
{{/*if not .Public/*}}
{{/*end*/}}
- {{if .PinnedPosts}}
- {{range .PinnedPosts}}{{.PlainDisplayTitle}} {{end}}
+ {{if .PinnedPosts}}
+ {{range .PinnedPosts}}{{.PlainDisplayTitle}} {{end}}
{{end}}
{{if .Posts}}
{{else}}{{end}}
{{if .ShowFooterBranding }}
{{ end }}
{{if .CanShowScript}}
{{range .ExternalScripts}}{{end}}
{{if .Script}}{{end}}
{{end}}
{{end}}
diff --git a/templates/collection-post.tmpl b/templates/collection-post.tmpl
index 7075226..a194cf4 100644
--- a/templates/collection-post.tmpl
+++ b/templates/collection-post.tmpl
@@ -1,130 +1,133 @@
{{define "post"}}
{{.PlainDisplayTitle}} {{localhtml "title dash" .Language.String}} {{.Collection.DisplayTitle}}
{{ if .IsFound }}
-
+
{{if gt .Views 1}}
{{end}}
{{if gt (len .Images) 0}} {{else}} {{end}}
-
+
{{range .Images}} {{else}} {{end}}
{{ end }}
{{if .Collection.StyleSheet}}{{end}}
{{if .Collection.RenderMathJax}}
{{template "mathjax" . }}
{{end}}
{{template "highlighting" .}}
{{if .PinnedPosts}}
- {{range .PinnedPosts}}{{.PlainDisplayTitle}} {{end}}
+ {{range .PinnedPosts}}{{.PlainDisplayTitle}} {{end}}
{{end}}
{{ if and .IsOwner .IsFound }}{{largeNumFmt .Views}} {{pluralize "view" "views" .Views}}
Edit
{{if .IsPinned}}Unpin {{end}}
{{ end }}
+ {{if .Suspended}}
+ {{template "user-suspended"}}
+ {{end}}
{{if .IsScheduled}}Scheduled
{{end}}{{if .Title.String}}{{.FormattedDisplayTitle}} {{end}}{{.HTMLContent}}
{{ if .Collection.ShowFooterBranding }}
{{ end }}
{{if .Collection.CanShowScript}}
{{range .Collection.ExternalScripts}}{{end}}
{{if .Collection.Script}}{{end}}
{{end}}
{{end}}
diff --git a/templates/collection-tags.tmpl b/templates/collection-tags.tmpl
index 7cad3b7..f209162 100644
--- a/templates/collection-tags.tmpl
+++ b/templates/collection-tags.tmpl
@@ -1,194 +1,197 @@
{{define "collection-tags"}}
{{.Tag}} — {{.Collection.DisplayTitle}}
{{if not .Collection.IsPrivate}} {{end}}
{{if gt .Views 1}}
{{end}}
{{if .Collection.StyleSheet}}{{end}}
{{if .Collection.RenderMathJax}}
{{template "mathjax" .}}
{{end}}
{{template "highlighting" . }}
+ {{if .Suspended}}
+ {{template "user-suspended"}}
+ {{end}}
{{if .Posts}}{{else}}{{end}}
{{.Tag}}
{{template "posts" .}}
{{if .Posts}}{{else}}{{end}}
{{ if .Collection.ShowFooterBranding }}
{{ end }}
{{if .CanShowScript}}
{{range .ExternalScripts}}{{end}}
{{if .Collection.Script}}{{end}}
{{end}}
{{if .IsOwner}}
{{end}}
{{end}}
diff --git a/templates/collection.tmpl b/templates/collection.tmpl
index 6623a2e..b87ce87 100644
--- a/templates/collection.tmpl
+++ b/templates/collection.tmpl
@@ -1,229 +1,233 @@
{{define "collection"}}
{{.DisplayTitle}}{{if not .SingleUser}} — {{.SiteName}}{{end}}
{{if gt .CurrentPage 1}} {{end}}
{{if lt .CurrentPage .TotalPages}} {{end}}
{{if not .IsPrivate}} {{end}}
{{if .StyleSheet}}{{end}}
{{if .RenderMathJax}}
{{template "mathjax" .}}
{{end}}
{{template "highlighting" . }}
{{if or .IsOwner .SingleUser}} {{end}}
+ {{if .Suspended}}
+ {{template "user-suspended"}}
+ {{end}}
{{if .Description}}{{.Description}}
{{end}}
{{/*if not .Public/*}}
{{/*end*/}}
{{if .PinnedPosts}}
- {{range .PinnedPosts}}{{.PlainDisplayTitle}} {{end}}
+ {{range .PinnedPosts}}{{.PlainDisplayTitle}} {{end}}
{{end}}
{{if .Posts}}{{else}}{{end}}
{{if .ShowFooterBranding }}
{{ end }}
{{if .CanShowScript}}
{{range .ExternalScripts}}{{end}}
{{if .Script}}{{end}}
{{end}}
{{end}}
diff --git a/templates/edit-meta.tmpl b/templates/edit-meta.tmpl
index 8d96b15..6707e68 100644
--- a/templates/edit-meta.tmpl
+++ b/templates/edit-meta.tmpl
@@ -1,370 +1,374 @@
{{define "edit-meta"}}
Edit metadata: {{if .Post.Title}}{{.Post.Title}}{{else}}{{.Post.Id}}{{end}} — {{.SiteName}}
{{end}}
diff --git a/templates/pad.tmpl b/templates/pad.tmpl
index 914d921..a8fca98 100644
--- a/templates/pad.tmpl
+++ b/templates/pad.tmpl
@@ -1,363 +1,367 @@
{{define "pad"}}
{{if .Editing}}Editing {{if .Post.Title}}{{.Post.Title}}{{else}}{{.Post.Id}}{{end}}{{else}}New Post{{end}} — {{.SiteName}}
{{end}}
diff --git a/templates/password-collection.tmpl b/templates/password-collection.tmpl
index e0b755d..73c4465 100644
--- a/templates/password-collection.tmpl
+++ b/templates/password-collection.tmpl
@@ -1,75 +1,78 @@
{{define "password-collection"}}
{{.DisplayTitle}}{{if not .SingleUser}} — {{.SiteName}}{{end}}
{{if .StyleSheet}}{{end}}
+ {{if .Suspended}}
+ {{template "user-supsended"}}
+ {{end}}
{{if and .Script .CanShowScript}}{{end}}
{{end}}
diff --git a/templates/post.tmpl b/templates/post.tmpl
index dd1375e..52d53a9 100644
--- a/templates/post.tmpl
+++ b/templates/post.tmpl
@@ -1,98 +1,101 @@
{{define "post"}}
{{if .Title}}{{.Title}}{{else}}{{.GenTitle}}{{end}} {{localhtml "title dash" .Language}} {{.SiteName}}
{{if .IsCode}}
{{end}}
{{if gt .Views 1}}
{{end}}
{{if .Author}} {{end}}
{{template "highlighting" .}}
-
{{largeNumFmt .Views}} {{pluralize "view" "views" .Views}}
{{if .IsCode}}View raw {{end}}
{{ if .Username }}
{{if .IsOwner}}
Edit
{{end}}
Drafts
{{ end }}
+
+ {{if .Suspended}}
+ {{template "user-suspended"}}
+ {{end}}
{{if .Title}}{{.Title}} {{end}}{{ if .IsPlainText }}{{.Content}}
{{ else }}{{.HTMLContent}}
{{ end }}
{{if .IsCode}}
{{else}}
{{if .IsPlainText}}{{end}}
{{end}}
{{end}}
diff --git a/templates/read.tmpl b/templates/read.tmpl
index cd03b9d..f1cbf29 100644
--- a/templates/read.tmpl
+++ b/templates/read.tmpl
@@ -1,127 +1,132 @@
{{define "head"}}{{.SiteName}} Reader
{{if gt .CurrentPage 1}} {{end}}
{{if lt .CurrentPage .TotalPages}} {{end}}
{{end}}
{{define "body-attrs"}}id="collection"{{end}}
{{define "content"}}
-
Reader
-
Read the latest posts from {{.SiteName}}. {{if .Username}}To showcase your writing here, go to your blog settings and select the Public option.{{end}}
+
{{.ContentTitle}}
+
{{if .SelTopic}}#{{.SelTopic}} posts{{else}}{{.Content}}{{end}}
{{ if gt (len .Posts) 0 }}
{{ else }}
{{ end }}
{{if gt .TotalPages 1}}
{{if lt .CurrentPage .TotalPages}}⇠ Older {{end}}
{{if gt .CurrentPage 1}}Newer ⇢ {{end}}
{{end}}
{{end}}
diff --git a/templates/user/admin/pages.tmpl b/templates/user/admin/pages.tmpl
index 25f7984..7a9e66a 100644
--- a/templates/user/admin/pages.tmpl
+++ b/templates/user/admin/pages.tmpl
@@ -1,34 +1,37 @@
{{define "pages"}}
{{template "header" .}}
{{template "admin-header" .}}
{{template "footer" .}}
{{end}}
diff --git a/templates/user/admin/users.tmpl b/templates/user/admin/users.tmpl
index b59104c..fb69d3a 100644
--- a/templates/user/admin/users.tmpl
+++ b/templates/user/admin/users.tmpl
@@ -1,31 +1,33 @@
{{define "users"}}
{{template "header" .}}
{{template "admin-header" .}}
User
Joined
Type
+ Status
{{range .Users}}
{{.Username}}
{{.CreatedFriendly}}
{{if .IsAdmin}}Admin{{else}}User{{end}}
+ {{if .IsSilenced}}Silenced{{else}}Active{{end}}
{{end}}
{{template "footer" .}}
{{end}}
diff --git a/templates/user/admin/view-page.tmpl b/templates/user/admin/view-page.tmpl
index 6d98b9d..161e40b 100644
--- a/templates/user/admin/view-page.tmpl
+++ b/templates/user/admin/view-page.tmpl
@@ -1,75 +1,77 @@
{{define "view-page"}}
{{template "header" .}}
{{template "admin-header" .}}
{{if eq .Content.ID "about"}}
Describe what your instance is about .
{{else if eq .Content.ID "privacy"}}
Outline your privacy policy .
+ {{else if eq .Content.ID "reader"}}
+
Customize your Reader page.
{{else if eq .Content.ID "landing"}}
Customize your home page .
{{end}}
{{if .Message}}
{{.Message}}
{{end}}
{{template "footer" .}}
{{end}}
diff --git a/templates/user/admin/view-user.tmpl b/templates/user/admin/view-user.tmpl
index 2a74e5b..7a7446c 100644
--- a/templates/user/admin/view-user.tmpl
+++ b/templates/user/admin/view-user.tmpl
@@ -1,87 +1,160 @@
{{define "view-user"}}
{{template "header" .}}
{{template "admin-header" .}}
-
+ {{if .NewPassword}}
+
This user's password has been reset to:
+
+
They can use this new password to log in to their account. This will only be shown once , so be sure to copy it and send it to them now.
+ {{if .ClearEmail}}
Their email address is: {{.ClearEmail}}
{{end}}
+
+ {{end}}
Blogs
{{range .Colls}}
Alias
{{.Alias}}
Title
{{.Title}}
Description
{{.Description}}
Visibility
{{.FriendlyVisibility}}
Views
{{.Views}}
Posts
{{.TotalPosts}}
Last Post
{{if .LastPost}}{{.LastPost}}{{else}}Never{{end}}
{{if $.Config.Federation}}
Fediverse Followers
{{.Followers}}
{{end}}
{{end}}
+
{{template "footer" .}}
{{end}}
diff --git a/templates/user/articles.tmpl b/templates/user/articles.tmpl
index 67d3e0b..3edb89c 100644
--- a/templates/user/articles.tmpl
+++ b/templates/user/articles.tmpl
@@ -1,145 +1,148 @@
{{define "articles"}}
{{template "header" .}}
{{if .Flashes}}
{{range .Flashes}}{{.}} {{end}}
{{end}}
+{{if .Suspended}}
+ {{template "user-suspended"}}
+{{end}}
{{ if .AnonymousPosts }}
{{ range $el := .AnonymousPosts }}
{{.DisplayDate}}
edit
delete
{{ if $.Collections }}
{{if gt (len $.Collections) 1}}
{{range $.Collections}}{{.DisplayTitle}} {{end}}
move to...
{{else}}
{{range $.Collections}}
move to {{.DisplayTitle}}
{{end}}
{{end}}
{{ end }}
{{if .Summary}}
{{.Summary}}
{{end}}
{{end}}
{{ else }}
You haven't saved any drafts yet.
They'll show up here once you do. {{if not .SingleUser}}Find your blog posts from the Blogs page.{{end}}
Start writing
{{ end }}
{{template "footer" .}}
{{end}}
diff --git a/templates/user/collection.tmpl b/templates/user/collection.tmpl
index 8af3bda..edd06c1 100644
--- a/templates/user/collection.tmpl
+++ b/templates/user/collection.tmpl
@@ -1,238 +1,241 @@
{{define "upgrade"}}
Upgrade for $40 / year to edit.
{{end}}
{{define "collection"}}
{{template "header" .}}
+ {{if .Suspended}}
+ {{template "user-suspended"}}
+ {{end}}
Customize {{.DisplayTitle}} view blog
{{if .Flashes}}
{{range .Flashes}}{{.}} {{end}}
{{end}}
URL
{{if eq .Alias .Username}}
This blog uses your username in its URL{{if .Federation}} and fediverse handle{{end}}. You can change it in your Account Settings .
{{end}}
{{.FriendlyHost}}/{{.Alias}} /
@{{.Alias}} @{{.FriendlyHost}}
Display Format
Customize how your posts display on your page.
Text Rendering
Customize how plain text renders on your blog.
Are you sure you want to delete this blog?
{{template "footer" .}}
{{end}}
diff --git a/templates/user/collections.tmpl b/templates/user/collections.tmpl
index 6ce4b75..7f6e83c 100644
--- a/templates/user/collections.tmpl
+++ b/templates/user/collections.tmpl
@@ -1,111 +1,114 @@
{{define "collections"}}
{{template "header" .}}
{{if .Flashes}}
{{range .Flashes}}{{.}} {{end}}
{{end}}
+{{if .Suspended}}
+ {{template "user-suspended"}}
+{{end}}
blogs
{{if not .NewBlogsDisabled}}
New blog
{{end}}
{{template "foot" .}}
{{template "body-end" .}}
{{end}}
diff --git a/templates/user/include/header.tmpl b/templates/user/include/header.tmpl
index e8fd908..3b57387 100644
--- a/templates/user/include/header.tmpl
+++ b/templates/user/include/header.tmpl
@@ -1,77 +1,108 @@
-{{define "header"}}
-
-
-
-
- {{.PageTitle}} {{if .Separator}}{{.Separator}}{{else}}—{{end}} {{.SiteName}}
-
-
-
-
-
-
-
-
-
-
-
-
+{{define "user-navigation"}}
+
{{if .SingleUser}}
Drafts
New Post
{{else}}
-
+
+
+ {{if .Username}}
+ {{end}}
- Blogs
- Drafts
+ {{if .SimpleNav}}
+ {{ if not .SingleUser }}
+ {{if and (and .LocalTimeline .CanViewReader) .Chorus}}Home {{end}}
+ {{ end }}
+ About
+ {{ if not .SingleUser }}
+ {{ if .Username }}
+ {{if gt .MaxBlogs 1}}Blogs {{end}}
+ {{if and .Chorus (eq .MaxBlogs 1)}}My Posts {{end}}
+ {{if not .DisableDrafts}}Drafts {{end}}
+ {{ end }}
+ {{if and (and .LocalTimeline .CanViewReader) (not .Chorus)}}Reader {{end}}
+ {{if and (and (and .Chorus .OpenRegistration) (not .Username)) (or (not .Private) (ne .Landing ""))}}Sign up {{end}}
+ {{if .Username}}Log out {{else}}Log in {{end}}
+ {{ end }}
+ {{else}}
+ Blogs
+ {{if not .DisableDrafts}}Drafts {{end}}
+ {{if and (and .LocalTimeline .CanViewReader) (not .Chorus)}}Reader {{end}}
+ {{end}}
+ {{if .Chorus}}{{if .Username}}{{end}}
+
+ {{end}}
{{end}}
+{{end}}
+{{define "header"}}
+
+
+
+
+ {{.PageTitle}} {{if .Separator}}{{.Separator}}{{else}}—{{end}} {{.SiteName}}
+
+
+
+
+
+
+
+
+
+
+
+ {{template "user-navigation" .}}
{{end}}
{{define "admin-header"}}
{{end}}
diff --git a/templates/user/include/suspended.tmpl b/templates/user/include/suspended.tmpl
new file mode 100644
index 0000000..76906de
--- /dev/null
+++ b/templates/user/include/suspended.tmpl
@@ -0,0 +1,5 @@
+{{define "user-suspended"}}
+
+
Your account has been silenced. You can still access all of your posts and blogs, but no one else can currently see them.
+
+{{end}}
diff --git a/templates/user/invite-help.tmpl b/templates/user/invite-help.tmpl
new file mode 100644
index 0000000..978cfad
--- /dev/null
+++ b/templates/user/invite-help.tmpl
@@ -0,0 +1,32 @@
+{{define "invite-help"}}
+{{template "header" .}}
+
+
+
Invite to {{.SiteName}}
+ {{ if .Expired }}
+
This invite link is expired.
+ {{ else }}
+
Copy the link below and send it to anyone that you want to join {{ .SiteName }} . You could paste it into an email, instant message, text message, or write it down on paper. Anyone who navigates to this special page will be able to create an account.
+
+
+ {{ if gt .Invite.MaxUses.Int64 0 }}
+ {{if eq .Invite.MaxUses.Int64 1}}Only one user{{else}}Up to {{.Invite.MaxUses.Int64}} users{{end}} can sign up with this link.
+ {{if gt .Invite.Uses 0}}So far, {{.Invite.Uses}} {{pluralize "person has" "people have" .Invite.Uses}} used it.{{end}}
+ {{if .Invite.Expires}}It expires on {{.Invite.ExpiresFriendly}} .{{end}}
+ {{ else }}
+ It can be used as many times as you like{{if .Invite.Expires}} before {{.Invite.ExpiresFriendly}} , when it expires{{end}}.
+ {{ end }}
+
+ {{ end }}
+
+
+{{template "footer" .}}
+{{end}}
diff --git a/templates/user/settings.tmpl b/templates/user/settings.tmpl
index fd204a5..d5cc33d 100644
--- a/templates/user/settings.tmpl
+++ b/templates/user/settings.tmpl
@@ -1,83 +1,86 @@
{{define "settings"}}
{{template "header" .}}
+ {{if .Suspended}}
+ {{template "user-suspended"}}
+ {{end}}
{{if .IsLogOut}}Before you go...{{else}}Account Settings {{if .IsAdmin}}admin settings {{end}}{{end}}
{{if .Flashes}}
{{range .Flashes}}{{.}} {{end}}
{{end}}
{{ if .IsLogOut }}
Please add an email address and/or passphrase so you can log in again later.
{{ else }}
Change your account settings here.
{{ end }}
Email
{{if and (not .Email) (not .IsLogOut)}}
Add your email to get:
No-passphrase login
Account recovery if you forget your passphrase
{{end}}
{{template "footer" .}}
{{end}}
diff --git a/templates/user/stats.tmpl b/templates/user/stats.tmpl
index f5588fb..705f1e0 100644
--- a/templates/user/stats.tmpl
+++ b/templates/user/stats.tmpl
@@ -1,53 +1,56 @@
{{define "stats"}}
{{template "header" .}}
+ {{if .Suspended}}
+ {{template "user-suspended"}}
+ {{end}}
Stats for all time.
{{if .Federation}}
Fediverse stats
Followers
{{.APFollowers}}
{{end}}
Top {{len .TopPosts}} posts
{{template "footer" .}}
{{end}}
diff --git a/unregisteredusers.go b/unregisteredusers.go
index 91daba5..b6f6ce6 100644
--- a/unregisteredusers.go
+++ b/unregisteredusers.go
@@ -1,144 +1,148 @@
/*
* Copyright © 2018-2019 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*
* WriteFreely is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, included
* in the LICENSE file in this source code package.
*/
package writefreely
import (
"database/sql"
"encoding/json"
+ "net/http"
+
"github.com/writeas/impart"
"github.com/writeas/web-core/log"
- "net/http"
)
func handleWebSignup(app *App, w http.ResponseWriter, r *http.Request) error {
- reqJSON := IsJSON(r.Header.Get("Content-Type"))
+ reqJSON := IsJSON(r)
// Get params
var ur userRegistration
if reqJSON {
decoder := json.NewDecoder(r.Body)
err := decoder.Decode(&ur)
if err != nil {
log.Error("Couldn't parse signup JSON request: %v\n", err)
return ErrBadJSON
}
} else {
err := r.ParseForm()
if err != nil {
log.Error("Couldn't parse signup form request: %v\n", err)
return ErrBadFormData
}
err = app.formDecoder.Decode(&ur, r.PostForm)
if err != nil {
log.Error("Couldn't decode signup form request: %v\n", err)
return ErrBadFormData
}
}
ur.Web = true
ur.Normalize = true
to := "/"
+ if app.cfg.App.SimpleNav {
+ to = "/new"
+ }
if ur.InviteCode != "" {
to = "/invite/" + ur.InviteCode
}
_, err := signupWithRegistration(app, ur, w, r)
if err != nil {
if err, ok := err.(impart.HTTPError); ok {
session, _ := app.sessionStore.Get(r, cookieName)
if session != nil {
session.AddFlash(err.Message)
session.Save(r, w)
return impart.HTTPError{http.StatusFound, to}
}
}
return err
}
return impart.HTTPError{http.StatusFound, to}
}
// { "username": "asdf" }
// result: { code: 204 }
func handleUsernameCheck(app *App, w http.ResponseWriter, r *http.Request) error {
- reqJSON := IsJSON(r.Header.Get("Content-Type"))
+ reqJSON := IsJSON(r)
// Get params
var d struct {
Username string `json:"username"`
}
if reqJSON {
decoder := json.NewDecoder(r.Body)
err := decoder.Decode(&d)
if err != nil {
log.Error("Couldn't decode username check: %v\n", err)
return ErrBadFormData
}
} else {
return impart.HTTPError{http.StatusNotAcceptable, "Must be JSON request"}
}
// Check if username is okay
finalUsername := getSlug(d.Username, "")
if finalUsername == "" {
errMsg := "Invalid username"
if d.Username != "" {
// Username was provided, but didn't convert into valid latin characters
errMsg += " - must have at least 2 letters or numbers"
}
return impart.HTTPError{http.StatusBadRequest, errMsg + "."}
}
if app.db.PostIDExists(finalUsername) {
return impart.HTTPError{http.StatusConflict, "Username is already taken."}
}
var un string
err := app.db.QueryRow("SELECT username FROM users WHERE username = ?", finalUsername).Scan(&un)
switch {
case err == sql.ErrNoRows:
return impart.WriteSuccess(w, finalUsername, http.StatusOK)
case err != nil:
log.Error("Couldn't SELECT username: %v", err)
return impart.HTTPError{http.StatusInternalServerError, "We messed up."}
}
// Username was found, so it's taken
return impart.HTTPError{http.StatusConflict, "Username is already taken."}
}
func getValidUsername(app *App, reqName, prevName string) (string, *impart.HTTPError) {
// Check if username is okay
finalUsername := getSlug(reqName, "")
if finalUsername == "" {
errMsg := "Invalid username"
if reqName != "" {
// Username was provided, but didn't convert into valid latin characters
errMsg += " - must have at least 2 letters or numbers"
}
return "", &impart.HTTPError{http.StatusBadRequest, errMsg + "."}
}
if finalUsername == prevName {
return "", &impart.HTTPError{http.StatusNotModified, "Username unchanged."}
}
if app.db.PostIDExists(finalUsername) {
return "", &impart.HTTPError{http.StatusConflict, "Username is already taken."}
}
var un string
err := app.db.QueryRow("SELECT username FROM users WHERE username = ?", finalUsername).Scan(&un)
switch {
case err == sql.ErrNoRows:
return finalUsername, nil
case err != nil:
log.Error("Couldn't SELECT username: %v", err)
return "", &impart.HTTPError{http.StatusInternalServerError, "We messed up."}
}
// Username was found, so it's taken
return "", &impart.HTTPError{http.StatusConflict, "Username is already taken."}
}
diff --git a/users.go b/users.go
index d5e9a91..9b5c99c 100644
--- a/users.go
+++ b/users.go
@@ -1,120 +1,132 @@
/*
* Copyright © 2018 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*
* WriteFreely is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, included
* in the LICENSE file in this source code package.
*/
package writefreely
import (
"time"
"github.com/guregu/null/zero"
"github.com/writeas/web-core/data"
"github.com/writeas/web-core/log"
"github.com/writeas/writefreely/key"
)
+type UserStatus int
+
+const (
+ UserActive = iota
+ UserSilenced
+)
+
type (
userCredentials struct {
Alias string `json:"alias" schema:"alias"`
Pass string `json:"pass" schema:"pass"`
Email string `json:"email" schema:"email"`
Web bool `json:"web" schema:"-"`
To string `json:"-" schema:"to"`
EmailLogin bool `json:"via_email" schema:"via_email"`
}
userRegistration struct {
userCredentials
InviteCode string `json:"invite_code" schema:"invite_code"`
Honeypot string `json:"fullname" schema:"fullname"`
Normalize bool `json:"normalize" schema:"normalize"`
Signup bool `json:"signup" schema:"signup"`
}
// AuthUser contains information for a newly authenticated user (either
// from signing up or logging in).
AuthUser struct {
AccessToken string `json:"access_token,omitempty"`
Password string `json:"password,omitempty"`
User *User `json:"user"`
// Verbose user data
Posts *[]PublicPost `json:"posts,omitempty"`
Collections *[]Collection `json:"collections,omitempty"`
}
// User is a consistent user object in the database and all contexts (auth
// and non-auth) in the API.
User struct {
ID int64 `json:"-"`
Username string `json:"username"`
HashedPass []byte `json:"-"`
HasPass bool `json:"has_pass"`
Email zero.String `json:"email"`
Created time.Time `json:"created"`
+ Status UserStatus `json:"status"`
clearEmail string `json:"email"`
}
userMeStats struct {
TotalCollections, TotalArticles, CollectionPosts uint64
}
ExportUser struct {
*User
Collections *[]CollectionObj `json:"collections"`
AnonymousPosts []PublicPost `json:"posts"`
}
PublicUser struct {
Username string `json:"username"`
}
)
// EmailClear decrypts and returns the user's email, caching it in the user
// object.
func (u *User) EmailClear(keys *key.Keychain) string {
if u.clearEmail != "" {
return u.clearEmail
}
if u.Email.Valid && u.Email.String != "" {
email, err := data.Decrypt(keys.EmailKey, []byte(u.Email.String))
if err != nil {
log.Error("Error decrypting user email: %v", err)
} else {
u.clearEmail = string(email)
return u.clearEmail
}
}
return ""
}
func (u User) CreatedFriendly() string {
/*
// TODO: accept a locale in this method and use that for the format
var loc monday.Locale = monday.LocaleEnUS
return monday.Format(u.Created, monday.DateTimeFormatsByLocale[loc], loc)
*/
return u.Created.Format("January 2, 2006, 3:04 PM")
}
// Cookie strips down an AuthUser to contain only information necessary for
// cookies.
func (u User) Cookie() *User {
u.HashedPass = []byte{}
return &u
}
func (u *User) IsAdmin() bool {
// TODO: get this from database
return u.ID == 1
}
+
+func (u *User) IsSilenced() bool {
+ return u.Status&UserSilenced != 0
+}
diff --git a/webfinger.go b/webfinger.go
index c95d88e..19116c6 100644
--- a/webfinger.go
+++ b/webfinger.go
@@ -1,82 +1,91 @@
/*
* Copyright © 2018 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*
* WriteFreely is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, included
* in the LICENSE file in this source code package.
*/
package writefreely
import (
+ "net/http"
+
"github.com/writeas/go-webfinger"
"github.com/writeas/impart"
"github.com/writeas/web-core/log"
"github.com/writeas/writefreely/config"
- "net/http"
)
type wfResolver struct {
db *datastore
cfg *config.Config
}
var wfUserNotFoundErr = impart.HTTPError{http.StatusNotFound, "User not found."}
func (wfr wfResolver) FindUser(username string, host, requestHost string, r []webfinger.Rel) (*webfinger.Resource, error) {
var c *Collection
var err error
if wfr.cfg.App.SingleUser {
c, err = wfr.db.GetCollectionByID(1)
} else {
c, err = wfr.db.GetCollection(username)
}
if err != nil {
log.Error("Unable to get blog: %v", err)
return nil, err
}
+ suspended, err := wfr.db.IsUserSuspended(c.OwnerID)
+ if err != nil {
+ log.Error("webfinger find user: check is suspended: %v", err)
+ return nil, err
+ }
+ if suspended {
+ return nil, wfUserNotFoundErr
+ }
c.hostName = wfr.cfg.App.Host
if wfr.cfg.App.SingleUser {
// Ensure handle matches user-chosen one on single-user blogs
if username != c.Alias {
log.Info("Username '%s' is not handle '%s'", username, c.Alias)
return nil, wfUserNotFoundErr
}
}
// Only return information if site has federation enabled.
// TODO: enable two levels of federation? Unlisted or Public on timelines?
if !wfr.cfg.App.Federation {
return nil, wfUserNotFoundErr
}
res := webfinger.Resource{
Subject: "acct:" + username + "@" + host,
Aliases: []string{
c.CanonicalURL(),
c.FederatedAccount(),
},
Links: []webfinger.Link{
{
HRef: c.CanonicalURL(),
Type: "text/html",
Rel: "https://webfinger.net/rel/profile-page",
},
{
HRef: c.FederatedAccount(),
Type: "application/activity+json",
Rel: "self",
},
},
}
return &res, nil
}
func (wfr wfResolver) DummyUser(username string, hostname string, r []webfinger.Rel) (*webfinger.Resource, error) {
return nil, wfUserNotFoundErr
}
func (wfr wfResolver) IsNotFoundError(err error) bool {
return err == wfUserNotFoundErr
}